@needle-tools/engine
Version:
Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.
437 lines (436 loc) • 19.9 kB
JavaScript
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
// For firefox ViewTimeline support
import "scroll-timeline-polyfill/dist/scroll-timeline.js";
import { Object3D } from "three";
import { isDevEnvironment } from "../../engine/debug/debug.js";
import { Mathf } from "../../engine/engine_math.js";
import { serializable } from "../../engine/engine_serialization.js";
import { getBoundingBox } from "../../engine/engine_three_utils.js";
import { getParam } from "../../engine/engine_utils.js";
import { Animation } from "../Animation.js";
import { Animator } from "../Animator.js";
import { AudioSource } from "../AudioSource.js";
import { Behaviour } from "../Component.js";
import { EventList } from "../EventList.js";
import { Light } from "../Light.js";
import { SplineWalker } from "../splines/SplineWalker.js";
import { PlayableDirector } from "../timeline/PlayableDirector.js";
const debug = getParam("debugscroll");
/**
* The [ScrollFollow](https://engine.needle.tools/docs/api/ScrollFollow) component allows you to link the scroll position of the page (or a specific element) to one or more target objects.
* This can be used to create scroll-based animations, audio playback, or other effects. For example you can link the scroll position to a timeline (PlayableDirector) to create scroll-based storytelling effects or to an Animator component to change the animation state based on scroll.
*
* 
* 
*
* Assign {@link target} objects to the component to have them updated based on the current scroll position (check the 'target' property for supported types).
*
* @link Example at https://scrollytelling-2-z23hmxby7c6x-u30ld.needle.run/
* @link Template at https://github.com/needle-engine/scrollytelling-template
* @link [Scrollytelling Bike Demo](https://scrollytelling-bike-z23hmxb2gnu5a.needle.run/)
*
* ## How to use with an Animator
* 1. Create an Animator component and set up a float parameter named "scroll".
* 2. Create transitions between animation states based on the "scroll" parameter (e.g. from 0 to 1).
* 3. Add a ScrollFollow component to the same GameObject or another GameObject in the scene.
* 4. Assign the Animator component to the ScrollFollow's target property.
*
* ## How to use with a PlayableDirector (timeline)
* 1. Create a PlayableDirector component and set up a timeline asset.
* 2. Add a ScrollFollow component to the same GameObject or another GameObject in the scene.
* 3. Assign the PlayableDirector component to the ScrollFollow's target property.
* 4. The timeline will now scrub based on the scroll position of the page.
* 5. (Optional) Add ScrollMarker markers to your HTML to define specific points in the timeline that correspond to elements on the page. For example:
* ```html
* <div data-timeline-marker="0.0">Start of Timeline</div>
* <div data-timeline-marker="0.5">Middle of Timeline</div>
* <div data-timeline-marker="1.0">End of Timeline</div>
* ```
*
* @summary Links scroll position to target objects
* @category Web
* @category Interaction
* @group Components
* @component
*/
export class ScrollFollow extends Behaviour {
/**
* Target object(s) to follow the scroll position of the page.
*
* Supported target types:
* - PlayableDirector (timeline), the scroll position will be mapped to the timeline time
* - Animator, the scroll position will be set to a float parameter named "scroll"
* - Animation, the scroll position will be mapped to the animation time
* - AudioSource, the scroll position will be mapped to the audio time
* - SplineWalker, the scroll position will be mapped to the position01 property
* - Light, the scroll position will be mapped to the intensity property
* - Object3D, the object will move vertically based on the scroll position
* - Any object with a `scroll` property (number or function)
*/
target = null;
/**
* Damping for the movement, set to 0 for instant movement
* @default 0
*/
damping = 0;
/**
* If true, the scroll value will be inverted (e.g. scrolling down will result in a value of 0)
* @default false
*/
invert = false;
/**
* **Experimental - might change in future updates**
* If set, the scroll position will be read from the specified element instead of the window.
* Use a CSS selector to specify the element, e.g. `#my-scrollable-div` or `.scroll-container`.
* @default null
*/
htmlSelector = null;
mode = "window";
/**
* Event fired when the scroll position changes
*/
changed = new EventList();
/**
* Current scroll value in "pages" (0 = top of page, 1 = bottom of page)
*/
get currentValue() {
return this._current_value;
}
_current_value = 0;
_target_value = 0;
_appliedValue = -1;
_needsUpdate = false;
_firstUpdate = false;
awake() {
this._firstUpdate = true;
}
/** @internal */
onEnable() {
window.addEventListener("wheel", this.updateCurrentScrollValue, { passive: true });
this._appliedValue = -1;
this._needsUpdate = true;
}
/** @internal */
onDisable() {
window.removeEventListener("wheel", this.updateCurrentScrollValue);
}
/** @internal */
lateUpdate() {
this.updateCurrentScrollValue();
if (this._target_value >= 0) {
if (this.damping > 0 && !this._firstUpdate) { // apply damping
this._current_value = Mathf.lerp(this._current_value, this._target_value, this.context.time.deltaTime / this.damping);
if (Math.abs(this._current_value - this._target_value) < 0.001) {
this._current_value = this._target_value;
}
}
else {
this._current_value = this._target_value;
}
}
if (this._needsUpdate || this._current_value !== this._appliedValue) {
this._appliedValue = this._current_value;
this._needsUpdate = false;
let defaultPrevented = false;
if (this.changed.listenerCount > 0) {
// fire change event
const event = {
type: "change",
value: this._current_value,
component: this,
preventDefault: () => { event.defaultPrevented = true; },
defaultPrevented: false,
};
this.changed.invoke(event);
defaultPrevented = event.defaultPrevented;
}
// if not prevented apply scroll
if (!defaultPrevented) {
const value = this.invert ? 1 - this._current_value : this._current_value;
// apply scroll to target(s)
if (Array.isArray(this.target)) {
this.target.forEach(t => t && this.applyScroll(t, value));
}
else if (this.target) {
this.applyScroll(this.target, value);
}
if (debug && this.context.time.frame % 30 === 0) {
console.debug(`[ScrollFollow] ${this._current_value.toFixed(5)} — ${(this._target_value * 100).toFixed(0)}%, targets [${Array.isArray(this.target) ? this.target.length : 1}]`);
}
}
this._firstUpdate = false;
}
}
_lastSelectorValue = null;
_lastSelectorElement = null;
updateCurrentScrollValue = () => {
switch (this.mode) {
case "window":
if (this.htmlSelector?.length) {
if (this.htmlSelector !== this._lastSelectorValue) {
this._lastSelectorElement = document.querySelector(this.htmlSelector);
this._lastSelectorValue = this.htmlSelector;
}
if (this._lastSelectorElement) {
const rect = this._lastSelectorElement.getBoundingClientRect();
this._target_value = -rect.top / (rect.height - window.innerHeight);
break;
}
}
else {
if (window.document.body.scrollHeight <= window.innerHeight) {
// If the page is not scrollable we can still increment the scroll value to allow triggering timelines etc.
}
else {
const diff = window.document.body.scrollHeight - window.innerHeight;
this._target_value = window.scrollY / (diff || 1);
}
}
break;
}
if (isNaN(this._target_value) || !isFinite(this._target_value))
this._target_value = -1;
};
applyScroll(target, value) {
if (!target)
return;
if (target instanceof PlayableDirector) {
this.handleTimelineTarget(target, value);
if (target.isPlaying)
target.pause();
target.evaluate();
}
else if (target instanceof Animator) {
target.setFloat("scroll", value);
}
else if (target instanceof Animation) {
target.time = value * target.duration;
}
else if (target instanceof AudioSource) {
if (!target.duration)
return;
target.time = value * target.duration;
}
else if (target instanceof SplineWalker) {
target.position01 = value;
}
else if (target instanceof Light) {
target.intensity = value;
}
else if (target instanceof Object3D) {
const t = target;
// When objects are assigned they're expected to move vertically based on scroll
if (t["needle:scrollbounds"] === undefined) {
t["needle:scrollbounds"] = getBoundingBox(target) || null;
}
const bounds = t["needle:scrollbounds"];
if (bounds) {
// TODO: remap position to use upper screen edge and lower edge instead of center
target.position.y = -bounds.min.y - value * (bounds.max.y - bounds.min.y);
}
}
else if ("scroll" in target) {
if (typeof target.scroll === "number") {
target.scroll = value;
}
else if (typeof target.scroll === "function") {
target.scroll(value);
}
}
}
handleTimelineTarget(director, value) {
const duration = director.duration;
let markersArray = timelineMarkerArrays.get(director);
// Create markers array
if (!markersArray) {
markersArray = [];
timelineMarkerArrays.set(director, markersArray);
let markerIndex = 0;
for (const marker of director.foreachMarker("ScrollMarker")) {
const index = markerIndex++;
// Get marker elements from DOM
if ((marker.element === undefined || marker.needsUpdate === true || /** element is not in DOM anymore? */ (marker.element && !marker.element?.parentNode))) {
marker.needsUpdate = false;
try {
// TODO: with this it's currently not possible to remap markers from HTML. For example if I have two sections and I want to now use the marker["center"] multiple times to stay at that marker for a longer time
marker.element = tryGetElementsForSelector(index);
if (debug)
console.debug(`ScrollMarker #${index} (${marker.time.toFixed(2)}) found`, marker.element);
if (!marker.element) {
if (debug || isDevEnvironment())
console.warn(`No HTML element found for ScrollMarker: ${marker.name} (index ${index})`);
continue;
}
}
catch (error) {
marker.element = null;
console.error("ScrollMarker selector is not valid: " + marker.name + "\n", error);
}
}
// skip markers without element (e.g. if the selector didn't return any element)
if (!marker.element)
continue;
markersArray.push(marker);
}
// If the timeline has no markers defined we can use timeline-marker elements in the DOM. These must define times then
if (markersArray.length <= 0) {
const markers = document.querySelectorAll(`[data-timeline-marker]`);
markers.forEach((element) => {
const value = element.getAttribute("data-timeline-marker");
const time = parseFloat(value || ("NaN"));
if (!isNaN(time)) {
markersArray.push({
time,
element: element,
});
}
else if (isDevEnvironment() || debug) {
console.warn("[ScrollFollow] data-timeline-marker attribute is not a valid number. Supported are numbers only (e.g. <div data-timeline-marker=\"0.5\">)");
}
});
}
// Init ViewTimeline for markers
for (const marker of markersArray) {
if (marker.element) {
// https://scroll-driven-animations.style/tools/view-timeline/ranges
/** @ts-ignore */
marker.timeline = new ViewTimeline({
subject: marker.element,
axis: 'block', // https://drafts.csswg.org/scroll-animations/#scroll-notation
});
}
}
}
weightsArray.length = 0;
let sum = 0;
const oneFrameTime = 1 / 60;
// We keep a separate count here in case there are some markers that could not be resolved so point to *invalid* elements - the timeline should fallback to 0-1 scroll behaviour then
let markerCount = 0;
for (let i = 0; i < markersArray.length; i++) {
const marker = markersArray[i];
if (!marker.element)
continue;
const nextMarker = markersArray[i + 1];
const nextTime = nextMarker
? (nextMarker.time - oneFrameTime)
: duration;
markerCount += 1;
const timeline = marker.timeline;
if (timeline) {
const time01 = calculateTimelinePositionNormalized(timeline);
// remap 0-1 to 0 - 1 - 0 (full weight at center)
const weight = 1 - Math.abs(time01 - 0.5) * 2;
const name = `marker${i}`;
if (time01 > 0 && time01 <= 1) {
const lerpTime = marker.time + (nextTime - marker.time) * time01;
weightsArray.push({ name, time: lerpTime, weight: weight });
sum += weight;
}
// Before the first marker is reached
else if (i === 0 && time01 <= 0) {
weightsArray.push({ name, time: 0, weight: 1 });
sum += 1;
}
// After the last marker is reached
else if (i === markersArray.length - 1 && time01 >= 1) {
weightsArray.push({ name, time: duration, weight: 1 });
sum += 1;
}
}
}
if (weightsArray.length <= 0 && markerCount <= 0) {
director.time = value * duration;
}
else if (weightsArray.length > 0) {
// normalize and calculate weighted time
let time = weightsArray[0].time; // fallback to first time
if (weightsArray.length > 1) {
for (const entry of weightsArray) {
const weight = entry.weight / Math.max(0.00001, sum);
// console.log(weight.toFixed(2))
// lerp time based on weight
const diff = Math.abs(entry.time - time);
time += diff * weight;
}
}
if (this.damping <= 0 || this._firstUpdate) {
director.time = time;
}
else {
director.time = Mathf.lerp(director.time, time, this.context.time.deltaTime / this.damping);
}
const delta = Math.abs(director.time - time);
if (delta > .001) { // if the time is > 1/100th of a second off we need another update
this._needsUpdate = true;
}
if (debug && this.context.time.frame % 30 === 0) {
console.log(`[ScrollFollow ] Timeline ${director.name}: ${time.toFixed(3)}`, weightsArray.map(w => `[${w.name} ${(w.weight * 100).toFixed(0)}%]`).join(", "));
}
}
}
}
__decorate([
serializable([Behaviour, Object3D])
], ScrollFollow.prototype, "target", void 0);
__decorate([
serializable()
], ScrollFollow.prototype, "damping", void 0);
__decorate([
serializable()
], ScrollFollow.prototype, "invert", void 0);
__decorate([
serializable()
], ScrollFollow.prototype, "htmlSelector", void 0);
__decorate([
serializable()
], ScrollFollow.prototype, "mode", void 0);
__decorate([
serializable(EventList)
], ScrollFollow.prototype, "changed", void 0);
const timelineMarkerArrays = new WeakMap();
const weightsArray = [];
// type SelectorCache = {
// /** The selector used to query the *elements */
// selector: string,
// elements: Element[] | null,
// usedElementCount: number,
// }
// const querySelectorResults: Array<SelectorCache> = [];
const needleScrollMarkerCache = new Array();
let needsScrollMarkerRefresh = true;
function tryGetElementsForSelector(index) {
if (!needsScrollMarkerRefresh) {
const element = needleScrollMarkerCache[index] || null;
return element;
}
needsScrollMarkerRefresh = false;
needleScrollMarkerCache.length = 0;
const markers = document.querySelectorAll(`[data-timeline-marker]`);
markers.forEach((m, i) => {
needleScrollMarkerCache[i] = m;
});
needsScrollMarkerRefresh = false;
return tryGetElementsForSelector(index);
}
// #region ScrollTimeline
function calculateTimelinePositionNormalized(timeline) {
if (!timeline.source)
return 0;
const currentTime = timeline.currentTime;
const duration = timeline.duration;
let durationValue = 1;
if (duration.unit === "seconds") {
durationValue = duration.value;
}
else if (duration.unit === "percent") {
durationValue = duration.value;
}
const t01 = currentTime.unit === "seconds" ? (currentTime.value / durationValue) : (currentTime.value / 100);
return t01;
}
//# sourceMappingURL=ScrollFollow.js.map