UNPKG

@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
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. * * ![](https://cloud.needle.tools/-/media/SYuH-vXxO4Jf30oU1HhjKQ.gif) * ![](https://cloud.needle.tools/-/media/RplmU_j7-xb8XHXkOzc9PA.gif) * * 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