diff --git a/examples/official-site/assets/landing/assets/sculpture-turns.avif b/examples/official-site/assets/landing/assets/sculpture-turns.avif new file mode 100644 index 00000000..83c8a59e Binary files /dev/null and b/examples/official-site/assets/landing/assets/sculpture-turns.avif differ diff --git a/examples/official-site/assets/landing/js/component-demos.js b/examples/official-site/assets/landing/js/component-demos.js index d53443bd..50046e5f 100644 --- a/examples/official-site/assets/landing/js/component-demos.js +++ b/examples/official-site/assets/landing/js/component-demos.js @@ -28,6 +28,12 @@ export function initComponentDemos(root, signal) { change(); const after = panel.getBoundingClientRect().height; if (media.matches) return; + if (root.dataset.renderer === "baked") { + // Retain the entrance fade without relaying every animated height through + // the page's section and sculpture ResizeObservers. + if (entering) fade = panel.animate([{ opacity: 0 }, { opacity: 1 }], 180); + return; + } animation = panel.animate( [ { height: `${before}px`, opacity: entering ? 0 : 1 }, diff --git a/examples/official-site/assets/landing/js/scene/database-scene.js b/examples/official-site/assets/landing/js/scene/database-scene.js index ab52a326..d1f93333 100644 --- a/examples/official-site/assets/landing/js/scene/database-scene.js +++ b/examples/official-site/assets/landing/js/scene/database-scene.js @@ -55,8 +55,6 @@ export function createDatabaseScene({ last = 0, time = 0; let renderedZoom = 0, - width = 1, - height = 1, visible = document.visibilityState !== "hidden", loaded = false, needsRender = true, @@ -69,50 +67,12 @@ export function createDatabaseScene({ const parallax = new THREE.Vector2(); const zeroPointer = new THREE.Vector2(); const lastOrientation = new THREE.Quaternion(); - const cleanup = []; - const materials = new Set(); - const geometries = new Set(); - const textures = new Set(); - const trackResources = (object) => { - object.traverse((child) => { - if ( - child instanceof THREE.Mesh || - child instanceof THREE.Line || - child instanceof THREE.Points - ) { - geometries.add(child.geometry); - for (const material of Array.isArray(child.material) - ? child.material - : [child.material]) { - materials.add(material); - for (const value of Object.values(material)) - if (value instanceof THREE.Texture) textures.add(value); - } - } - }); - }; - const disposeObject = (object) => { - object.traverse((child) => { - if (child instanceof THREE.Mesh) { - child.geometry.dispose(); - for (const material of Array.isArray(child.material) - ? child.material - : [child.material]) - material.dispose(); - } - }); - }; // Fetch the model, baked environment and shader sources in parallel. Any // asset failure returns the experience to its static preview. let environment; const assets = Promise.all([ - new GLTFLoader().loadAsync(MODEL_URL).then((gltf) => { - // Track successful geometry even if the parallel lighting load fails. - if (disposed) disposeObject(gltf.scene); - else trackResources(gltf.scene); - return gltf; - }), + new GLTFLoader().loadAsync(MODEL_URL), loadSculptureShaders(), loadMetalEnvironment().then((texture) => { environment = texture; @@ -128,7 +88,92 @@ export function createDatabaseScene({ renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 0.95; renderer.setClearColor(0x000000, 0); - mount.appendChild(renderer.domElement); + let canvas = renderer.domElement; + const canvasStyle = + "position:absolute;left:0;top:0;width:1000px;height:1000px;transform-origin:0 0"; + canvas.style.cssText = canvasStyle; + mount.appendChild(canvas); + const root = mount.closest(".sqlpage-world"); + delete root.dataset.renderer; + const instructions = hitArea.querySelector("#view-instructions"); + const originalInstructions = instructions?.textContent; + const gl = renderer.getContext(); + let fence, + submittedAt = 0, + samples = 0, + slowFrames = 0, + cpuBusyUntil = 0; + let baked, context, lastTurn; + const resetBudget = () => { + if (fence) gl.deleteSync(fence); + fence = undefined; + samples = slowFrames = 0; + }; + let timing; + try { + timing = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) + if ( + entry.scripts.reduce((total, script) => total + script.duration, 0) >= + 50 + ) + cpuBusyUntil = entry.startTime + entry.duration; + }); + timing.observe({ type: "long-animation-frame" }); + } catch { + /* Optional. */ + } + const releaseGraphics = () => { + renderer.domElement.removeEventListener("webglcontextlost", onLost); + resetBudget(); + glow?.dispose(); + environment?.dispose(); + renderer.dispose(); + // This scene owns the context; losing it releases all its GPU allocations. + renderer.forceContextLoss(); + }; + const useBakedViews = () => { + releaseGraphics(); + baked = new Image(); + const replacement = document.createElement("canvas"); + replacement.width = 320; + replacement.height = 400; + context = replacement.getContext("2d"); + if (!context) { + onError(); + return; + } + canvas.replaceWith(replacement); + canvas = replacement; + canvas.style.cssText = canvasStyle; + // Keep the square layout while rasterizing only the cropped sculpture. + Object.assign(canvas.style, { + boxSizing: "content-box", + width: "700px", + height: "850px", + padding: "75px 150px", + }); + root.dataset.renderer = "baked"; + root.dataset.scene = "loading"; + loaded = readySent = false; + hitArea.inert = true; + if (instructions) + instructions.textContent = + "Drag sideways or use left/right arrows to rotate. Use plus/minus to zoom and Home to reset. Swipe vertically or pinch to scroll or zoom the page."; + baked.src = new URL( + "../../assets/sculpture-turns.avif", + import.meta.url, + ).href; + baked.decode().then( + () => { + loaded = !disposed; + needsRender = true; + }, + () => { + if (!disposed) onError(); + }, + ); + }; const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 70); cameraForSculpture(camera, CAMERA_DISTANCE); @@ -152,10 +197,8 @@ export function createDatabaseScene({ fill.position.set(-5, -0.5, -2); scene.add(fill); - const placement = new THREE.Group(); - scene.add(placement); const parallaxPivot = new THREE.Group(); - placement.add(parallaxPivot); + scene.add(parallaxPivot); const scrollPivot = new THREE.Group(); parallaxPivot.add(scrollPivot); const dragPivot = new THREE.Group(); @@ -180,7 +223,6 @@ export function createDatabaseScene({ jewelReflection = createFiniteJewelLight(JEWEL_EDGES, shaders); decoration = createSculptureDecoration(shaders); parallaxPivot.add(decoration.group); - trackResources(decoration.group); performance.mark("sqlpage:assets-ready"); const bounds = new THREE.Box3().setFromObject(gltf.scene); bounds.getCenter(modelCenter); @@ -214,7 +256,6 @@ export function createDatabaseScene({ if (material.transparent) material.depthWrite = false; } }); - trackResources(gltf.scene); const jewelLight = new THREE.PointLight(0x72c8de, 0.09, 2.4, 2); jewelLight.position.set(0, 1.35, 0); gltf.scene.add(jewelLight); @@ -224,7 +265,6 @@ export function createDatabaseScene({ dragPivot.add(gltf.scene); await document.fonts.ready; if (disposed) return; - resize(); // Use parallel shader compilation when supported. The loading state ends // only after a frame containing the sculpture and its glow is submitted. await renderer.compileAsync(scene, camera); @@ -243,24 +283,9 @@ export function createDatabaseScene({ scrollPivot, pointer, isLoaded: () => loaded, + yawOnly: () => !!baked, }); const { pointers, dragQuaternion, zoom, reset } = controls; - cleanup.push(() => controls.dispose()); - - const resize = () => { - width = Math.max(1, mount.clientWidth); - height = Math.max(1, mount.clientHeight); - needsRender = true; - }; - const observer = new ResizeObserver(resize); - observer.observe(mount); - resize(); - window.addEventListener("resize", resize); - window.visualViewport?.addEventListener("resize", resize); - cleanup.push(() => { - window.removeEventListener("resize", resize); - window.visualViewport?.removeEventListener("resize", resize); - }); const onLost = (event) => { event.preventDefault(); contextLost = true; @@ -269,48 +294,42 @@ export function createDatabaseScene({ }; renderer.domElement.addEventListener("webglcontextlost", onLost); const modelBounds = new THREE.Box3(), - projectedCorner = new THREE.Vector3(); + projection = new THREE.Matrix4(); const screenBounds = { left: 0, right: 1, top: 0, bottom: 1 }; const syncHitArea = () => { if (!model) return; - modelBounds.setFromObject(model); - let left = Infinity, - right = -Infinity, - top = Infinity, - bottom = -Infinity; - for (let i = 0; i < 8; i++) { - projectedCorner - .set( - i & 1 ? modelBounds.max.x : modelBounds.min.x, - i & 2 ? modelBounds.max.y : modelBounds.min.y, - i & 4 ? modelBounds.max.z : modelBounds.min.z, - ) - .project(camera); - const x = ((projectedCorner.x + 1) * renderer.domElement.width) / 2, - y = ((1 - projectedCorner.y) * renderer.domElement.height) / 2; - left = Math.min(left, x); - right = Math.max(right, x); - top = Math.min(top, y); - bottom = Math.max(bottom, y); + // Box3 projects the same eight corners; avoid duplicating its min/max loop. + if (baked) { + modelBounds.min.set(-0.44, -0.62, 0); + modelBounds.max.set(0.42, 0.56, 0); + } else { + modelBounds + .setFromObject(model) + .applyMatrix4( + projection.multiplyMatrices( + camera.projectionMatrix, + camera.matrixWorldInverse, + ), + ); } - screenBounds.left = left; - screenBounds.right = right; - screenBounds.top = top; - screenBounds.bottom = bottom; + const { min, max } = modelBounds; + screenBounds.left = (min.x + 1) * 500; + screenBounds.right = (max.x + 1) * 500; + screenBounds.top = (1 - max.y) * 500; + screenBounds.bottom = (1 - min.y) * 500; if (pointers.size) return; - left = canvasLeft + (left / renderer.domElement.width) * canvasWidth; - right = canvasLeft + (right / renderer.domElement.width) * canvasWidth; - top = canvasTop + (top / renderer.domElement.height) * canvasWidth; - bottom = canvasTop + (bottom / renderer.domElement.height) * canvasWidth; - left = clamp(left - 12, 0, width); - right = clamp(right + 12, 0, width); - top = clamp(top - 12, 90, height); - bottom = clamp(bottom + 12, top, height); + const scale = canvasWidth / 1000; + let { left, right, top, bottom } = screenBounds; + left = clamp(canvasLeft + left * scale - 12, 0, innerWidth); + right = clamp(canvasLeft + right * scale + 12, left, innerWidth); + top = clamp(canvasTop + top * scale - 12, 90, innerHeight); + bottom = clamp(canvasTop + bottom * scale + 12, top, innerHeight); hitArea.style.left = `${left}px`; hitArea.style.top = `${top}px`; hitArea.style.width = `${right - left}px`; hitArea.style.height = `${bottom - top}px`; }; + const yaw = new THREE.Euler(0, 0, 0, "YXZ"); const upAxis = new THREE.Vector3(0, 1, 0); const update = (timestamp) => { if (disposed || contextLost || !visible) return; @@ -320,9 +339,9 @@ export function createDatabaseScene({ const ease = 1 - Math.exp(-dt * 8); renderedZoom = THREE.MathUtils.lerp(renderedZoom, controls.userZoom, ease); // Loading time and pointer movement must not advance the reference frame. - if (loaded && readySent && state.motion) time += dt; + if (!baked && loaded && readySent && state.motion) time += dt; parallax.lerp( - readySent && state.motion ? pointer : zeroPointer, + !baked && readySent && state.motion ? pointer : zeroPointer, 1 - Math.exp(-dt * 3.7), ); controls.update(dt, readySent && state.motion); @@ -333,37 +352,16 @@ export function createDatabaseScene({ parallaxPivot.rotation.set(parallax.y * 0.035, parallax.x * 0.055, 0); const distance = clamp(CAMERA_DISTANCE * Math.exp(renderedZoom), 5.9, 17); cameraForSculpture(camera, distance, parallax.x * 0.11, parallax.y * 0.075); - // One square renderer follows measured section anchors. The model, lighting, - // camera and materials stay the same throughout the page. + // Both paths follow the same square frame and section anchors. const position = state.frame; - canvasLeft = position.left; - canvasTop = position.top; - canvasWidth = position.size; + canvasWidth = position.size * (baked ? Math.exp(-renderedZoom) : 1); + canvasLeft = position.left + (position.size - canvasWidth) / 2; + canvasTop = position.top + (position.size - canvasWidth) / 2; const frameKey = `${canvasLeft},${canvasTop},${canvasWidth},${state.turn}`; if (frameKey !== lastFrame) needsRender = true; lastFrame = frameKey; - renderer.domElement.dataset.scrollTurn = String(state.turn ?? 0); - Object.assign(renderer.domElement.style, { - position: "absolute", - left: `${canvasLeft}px`, - top: `${canvasTop}px`, - width: `${canvasWidth}px`, - height: `${canvasWidth}px`, - }); - decoration?.update(time, state.motion); - liquidClock.value = time; - if (diamond) { - diamond.position.y = Math.sin(time * 0.62) * 0.012; - diamond.rotation.y = Math.sin(time * 0.12) * 0.055; - } - interiorLight.position.set( - Math.cos(time * ((Math.PI * 2) / 6)) * 0.4, - -0.45 + Math.sin(time * ((Math.PI * 2) / 4)) * 0.28, - Math.sin(time * ((Math.PI * 2) / 6)) * 0.4, - ); - const pulse = 14 + Math.sin(time * ((Math.PI * 2) / 3)) * 6; - interior.uInteriorPulse.value = pulse; - interiorLight.intensity = (0.6 * pulse) / 14; + canvas.dataset.scrollTurn = String(state.turn ?? 0); + canvas.style.transform = `translate3d(${canvasLeft}px,${canvasTop}px,0) scale(${canvasWidth / 1000})`; const changing = Math.abs(renderedZoom - controls.userZoom) > 0.0001 || pointers.size > 0 || @@ -373,24 +371,93 @@ export function createDatabaseScene({ state.opacity > 0.05 && canvasTop + canvasWidth * 0.81 > 0 && canvasTop + canvasWidth * 0.22 < window.innerHeight; + if (!inView) resetBudget(); if (readySent) { hitArea.inert = !inView; hitArea.tabIndex = inView ? 0 : -1; } + // Poll even when no redraw is needed: idle time is not graphics latency. + if (!baked && fence) { + const pending = gl.clientWaitSync(fence, 0, 0) === gl.TIMEOUT_EXPIRED; + const elapsed = performance.now() - submittedAt; + if (pending && elapsed <= 1000) { + frame = requestAnimationFrame(update); + return; + } + gl.deleteSync(fence); + fence = undefined; + if (samples++ >= 2) + slowFrames = + elapsed > 34 && cpuBusyUntil < submittedAt ? slowFrames + 1 : 0; + if (pending || slowFrames >= 4) { + useBakedViews(); + frame = requestAnimationFrame(update); + return; + } + } if ( loaded && (inView || !readySent) && (state.motion || needsRender || changing) ) { - scene.updateMatrixWorld(true); - interiorLight.getWorldPosition(interior.uInteriorPosition.value); - interiorLight.getWorldScale(interiorScale); - interior.uInteriorRadius.value = Math.abs(interiorScale.x) * 1.15; - syncHitArea(); - if (diamond && wireMaterial) - jewelReflection.update(diamond, camera, wireMaterial); - renderer.render(scene, camera); - glow.render(model, [decoration.group], screenBounds); + if (baked) { + syncHitArea(); + // 64 full-material/glow views, cropped from the original 1000px square. + const turn = + (((state.turn ?? 0) + + yaw.setFromQuaternion(dragQuaternion, "YXZ").y) / + (2 * Math.PI)) * + 64; + if (turn !== lastTurn) { + const whole = Math.floor(turn), + blend = turn - whole; + context.clearRect(0, 0, 320, 400); + for (let i = 0; i < 2; i++) { + const index = (((whole + i) % 64) + 64) % 64; + context.globalAlpha = i ? blend : 1 - blend; + context.globalCompositeOperation = i ? "lighter" : "source-over"; + context.drawImage( + baked, + (index % 8) * 320, + Math.floor(index / 8) * 400, + 320, + 400, + 0, + 0, + 320, + 400, + ); + } + lastTurn = turn; + } + } else { + // Prepare GPU-only animation only when a new live frame can be drawn. + decoration?.update(time, state.motion); + liquidClock.value = time; + if (diamond) { + diamond.position.y = Math.sin(time * 0.62) * 0.012; + diamond.rotation.y = Math.sin(time * 0.12) * 0.055; + } + interiorLight.position.set( + Math.cos(time * ((Math.PI * 2) / 6)) * 0.4, + -0.45 + Math.sin(time * ((Math.PI * 2) / 4)) * 0.28, + Math.sin(time * ((Math.PI * 2) / 6)) * 0.4, + ); + const pulse = 14 + Math.sin(time * ((Math.PI * 2) / 3)) * 6; + interior.uInteriorPulse.value = pulse; + interiorLight.intensity = (0.6 * pulse) / 14; + scene.updateMatrixWorld(true); + interiorLight.getWorldPosition(interior.uInteriorPosition.value); + interiorLight.getWorldScale(interiorScale); + interior.uInteriorRadius.value = Math.abs(interiorScale.x) * 1.15; + if (diamond && wireMaterial) + jewelReflection.update(diamond, camera, wireMaterial); + syncHitArea(); + renderer.render(scene, camera); + glow.render(model, [decoration.group]); + fence = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0); + submittedAt = performance.now(); + } needsRender = false; lastOrientation.copy(dragQuaternion); if (!readySent) { @@ -408,6 +475,7 @@ export function createDatabaseScene({ }; const onVisibility = () => { visible = document.visibilityState !== "hidden"; + resetBudget(); if (visible && !disposed && !contextLost) { last = performance.now(); cancelAnimationFrame(frame); @@ -424,19 +492,13 @@ export function createDatabaseScene({ if (disposed) return; disposed = true; cancelAnimationFrame(frame); - observer.disconnect(); - for (const fn of cleanup) fn(); + controls.dispose(); document.removeEventListener("visibilitychange", onVisibility); - renderer.domElement.removeEventListener("webglcontextlost", onLost); - for (const geometry of geometries) geometry.dispose(); - for (const material of materials) material.dispose(); - if (environment) textures.delete(environment); - for (const texture of textures) texture.dispose(); - glow?.dispose(); - environment?.dispose(); - renderer.dispose(); - renderer.forceContextLoss(); - renderer.domElement.remove(); + timing?.disconnect(); + if (instructions) instructions.textContent = originalInstructions; + if (baked) baked.src = ""; + if (!baked) releaseGraphics(); + canvas.remove(); }, }; } diff --git a/examples/official-site/assets/landing/js/scene/sculpture-controls.js b/examples/official-site/assets/landing/js/scene/sculpture-controls.js index 55e65c57..f34f1c38 100644 --- a/examples/official-site/assets/landing/js/scene/sculpture-controls.js +++ b/examples/official-site/assets/landing/js/scene/sculpture-controls.js @@ -24,6 +24,7 @@ export function createSculptureControls({ scrollPivot, pointer, isLoaded, + yawOnly = () => false, }) { let userZoom = 0; const dragQuaternion = new THREE.Quaternion(); @@ -40,6 +41,7 @@ export function createSculptureControls({ let touchStartX = 0, touchStartY = 0; const pointInParent = (x, y, out) => { + if (yawOnly()) return out.set(Math.sin(x * 0.012), 0, Math.cos(x * 0.012)); out.copy(trackballVector(x, y, hitArea.getBoundingClientRect())); // Screen-space gestures are transformed into the rotating parent's coordinates. out.applyQuaternion(camera.quaternion); @@ -124,30 +126,23 @@ export function createSculptureControls({ pointer.set(0, 0); }; const onKey = (event) => { - const axis = new THREE.Vector3(); - let angle = 0.17; - if (event.key === "Home") { - event.preventDefault(); - reset(); - return; - } - if (["+", "=", "-", "_"].includes(event.key)) { - event.preventDefault(); - zoom(event.key === "-" || event.key === "_" ? 0.1 : -0.1); - return; + const key = event.key; + if (key === "Home") reset(); + else if (["+", "=", "-", "_"].includes(key)) + zoom(key === "-" || key === "_" ? 0.1 : -0.1); + else { + const horizontal = ["ArrowLeft", "ArrowRight"].includes(key); + const vertical = ["ArrowUp", "ArrowDown"].includes(key); + if ((!horizontal && !vertical) || (vertical && yawOnly())) return; + const roll = horizontal && event.shiftKey && !yawOnly(); + const axis = new THREE.Vector3(+vertical, +(horizontal && !roll), +roll); + const angle = key === "ArrowLeft" || key === "ArrowUp" ? -0.17 : 0.17; + angularVelocity = 0; + dragQuaternion + .premultiply(localQuaternion.setFromAxisAngle(axis, angle)) + .normalize(); } - if (event.key === "ArrowLeft" || event.key === "ArrowRight") { - axis.set(0, event.shiftKey ? 0 : 1, event.shiftKey ? 1 : 0); - if (event.key === "ArrowLeft") angle = -angle; - } else if (event.key === "ArrowUp" || event.key === "ArrowDown") { - axis.set(1, 0, 0); - if (event.key === "ArrowUp") angle = -angle; - } else return; event.preventDefault(); - angularVelocity = 0; - dragQuaternion - .premultiply(localQuaternion.setFromAxisAngle(axis, angle)) - .normalize(); }; const onParallax = (event) => { if (event.pointerType !== "mouse" || pointers.size) return; @@ -166,24 +161,23 @@ export function createSculptureControls({ ); }; const leave = () => pointer.set(0, 0); - hitArea.addEventListener("pointerdown", onPointerDown); - hitArea.addEventListener("pointermove", onPointerMove); - hitArea.addEventListener("pointerup", onPointerUp); - hitArea.addEventListener("pointercancel", onPointerUp); - hitArea.addEventListener("lostpointercapture", onPointerUp); - hitArea.addEventListener("keydown", onKey); - window.addEventListener("pointermove", onParallax, { passive: true }); - document.documentElement.addEventListener("pointerleave", leave); - const dispose = () => { - hitArea.removeEventListener("pointerdown", onPointerDown); - hitArea.removeEventListener("pointermove", onPointerMove); - hitArea.removeEventListener("pointerup", onPointerUp); - hitArea.removeEventListener("pointercancel", onPointerUp); - hitArea.removeEventListener("lostpointercapture", onPointerUp); - hitArea.removeEventListener("keydown", onKey); - window.removeEventListener("pointermove", onParallax); - document.documentElement.removeEventListener("pointerleave", leave); - }; + const events = new AbortController(); + for (const [type, handler] of Object.entries({ + pointerdown: onPointerDown, + pointermove: onPointerMove, + pointerup: onPointerUp, + pointercancel: onPointerUp, + lostpointercapture: onPointerUp, + keydown: onKey, + })) + hitArea.addEventListener(type, handler, { signal: events.signal }); + window.addEventListener("pointermove", onParallax, { + passive: true, + signal: events.signal, + }); + document.documentElement.addEventListener("pointerleave", leave, { + signal: events.signal, + }); return { pointers, @@ -211,7 +205,7 @@ export function createSculptureControls({ if (hitArea.hasPointerCapture(id)) hitArea.releasePointerCapture(id); } pointers.clear(); - dispose(); + events.abort(); }, }; } diff --git a/examples/official-site/assets/landing/js/scene/sculpture-decoration.js b/examples/official-site/assets/landing/js/scene/sculpture-decoration.js index 60d2d8dd..95c276dc 100644 --- a/examples/official-site/assets/landing/js/scene/sculpture-decoration.js +++ b/examples/official-site/assets/landing/js/scene/sculpture-decoration.js @@ -3,9 +3,14 @@ import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.186.1/+esm"; export function createSculptureDecoration(shaders) { const decoration = new THREE.Group(); - const orbitalNodes = []; const orbMaterial = new THREE.MeshBasicMaterial({ color: 0x377e94 }); const orbGeometry = new THREE.SphereGeometry(0.015, 10, 8); + const orbs = new THREE.InstancedMesh(orbGeometry, orbMaterial, 8); + orbs.instanceMatrix.setUsage(THREE.DynamicDrawUsage); + // All eight moving nodes stay inside the orbital tracks. + orbs.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 4); + decoration.add(orbs); + const transform = new THREE.Object3D(); for (let i = 0; i < 4; i++) { const rx = 1.65 + i * 0.46, rz = 1.24 + i * 0.22, @@ -26,19 +31,6 @@ export function createSculptureDecoration(shaders) { depthWrite: false, }); decoration.add(new THREE.LineLoop(geometry, material)); - for (let j = 0; j < 2; j++) { - const orb = new THREE.Mesh(orbGeometry, orbMaterial); - orb.scale.setScalar(j === 0 ? 1 : 0.66); - decoration.add(orb); - orbitalNodes.push({ - object: orb, - rx, - rz, - y, - phase: i * 1.34 + j * Math.PI, - speed: 0.09 + i * 0.013, - }); - } } const starsGeometry = new THREE.BufferGeometry(); @@ -88,14 +80,20 @@ export function createSculptureDecoration(shaders) { update(time, motion) { if (motion) decoration.rotation.y = time * 0.014; starsMaterial.uniforms.uTime.value = time; - for (const orb of orbitalNodes) { - const a = orb.phase + time * orb.speed; - orb.object.position.set( - Math.cos(a) * orb.rx, - orb.y + Math.sin(a) * 0.07, - Math.sin(a) * orb.rz, + for (let i = 0; i < 8; i++) { + const ring = Math.floor(i / 2); + const a = + ring * 1.34 + (i % 2) * Math.PI + time * (0.09 + ring * 0.013); + transform.scale.setScalar(i % 2 === 0 ? 1 : 0.66); + transform.position.set( + Math.cos(a) * (1.65 + ring * 0.46), + -1.3 - ring * 0.18 + Math.sin(a) * 0.07, + Math.sin(a) * (1.24 + ring * 0.22), ); + transform.updateMatrix(); + orbs.setMatrixAt(i, transform.matrix); } + orbs.instanceMatrix.needsUpdate = true; }, }; } diff --git a/examples/official-site/assets/landing/js/scene/selective-glow.js b/examples/official-site/assets/landing/js/scene/selective-glow.js index d99bc2d8..2e7750ae 100644 --- a/examples/official-site/assets/landing/js/scene/selective-glow.js +++ b/examples/official-site/assets/landing/js/scene/selective-glow.js @@ -67,6 +67,12 @@ export function createSelectiveGlow( return material; }; + const meshes = []; + const lightBounds = new THREE.Box3(), + projected = new THREE.Box3(); + const projection = new THREE.Matrix4(), + transform = new THREE.Matrix4(); + let cachedModel; const blur = new THREE.ShaderMaterial({ uniforms: { tSource: { value: source.texture }, @@ -126,8 +132,50 @@ export function createSelectiveGlow( height = h; ratio = Math.min(pixelRatio, 1.25); }, - render(model, exclude, bounds) { + render(model, exclude) { if (disposed || !model) return; + // The asset's materials are fixed after setup. Cache the mask once; + // crop around emitters, while retaining every opaque occluder in the pass. + if (cachedModel !== model) { + cachedModel = model; + meshes.length = 0; + model.traverse((mesh) => { + if (!mesh.isMesh) return; + const material = mesh.material; + const mask = Array.isArray(material) + ? material.map(glowMaterial) + : glowMaterial(material); + meshes.push({ + mesh, + material, + mask, + luminous: [].concat(mask).some((m) => m !== black && m !== omitted), + }); + }); + } + projection.multiplyMatrices( + camera.projectionMatrix, + camera.matrixWorldInverse, + ); + lightBounds.makeEmpty(); + for (const { mesh, luminous } of meshes) { + if (!luminous || !mesh.visible) continue; + if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox(); + lightBounds.union( + projected + .copy(mesh.geometry.boundingBox) + .applyMatrix4( + transform.multiplyMatrices(projection, mesh.matrixWorld), + ), + ); + } + if (lightBounds.isEmpty()) return; + const bounds = { + left: ((lightBounds.min.x + 1) * width) / 2, + right: ((lightBounds.max.x + 1) * width) / 2, + top: ((1 - lightBounds.max.y) * height) / 2, + bottom: ((1 - lightBounds.min.y) * height) / 2, + }; // The mesh keeps its full resolution. Only its soft halo is capped; // enlarge the guard band to accommodate blur at any effective ratio. const minimumRatio = Math.min(ratio, 1152 / Math.max(width, height)); @@ -142,13 +190,14 @@ export function createSelectiveGlow( height, cropHeight, ); + // Keep both downsampled blur grids stable as the crop moves. const left = THREE.MathUtils.clamp( - Math.round((bounds.left + bounds.right - cropWidth) * 0.5), + Math.floor((bounds.left + bounds.right - cropWidth) / 8) * 4, 0, width - cropWidth, ); const top = THREE.MathUtils.clamp( - Math.round((bounds.top + bounds.bottom - cropHeight) * 0.5), + Math.floor((bounds.top + bounds.bottom - cropHeight) / 8) * 4, 0, height - cropHeight, ); @@ -182,22 +231,17 @@ export function createSelectiveGlow( const hidden = exclude .filter((object) => !!object) .map((object) => ({ object, visible: object.visible })); - const meshes = []; try { hidden.forEach(({ object }) => { object.visible = false; }); - model.traverse((object) => { - if (!(object instanceof THREE.Mesh) || !object.visible) return; - meshes.push({ mesh: object, material: object.material }); - object.material = Array.isArray(object.material) - ? object.material.map(glowMaterial) - : glowMaterial(object.material); - }); + for (const { mesh, mask } of meshes) mesh.material = mask; renderer.autoClear = true; renderer.setClearColor(0x000000, 0); renderer.setRenderTarget(source); renderer.render(scene, maskCamera); + // Opaque fullscreen quads overwrite every pixel; clearing is redundant. + renderer.autoClear = false; blurInto(source, narrowH, 2, 0, 1); blurInto(narrowH, narrowV, 0, 1); blurInto(narrowV, wideH, 2, 0); diff --git a/examples/official-site/assets/landing/js/scroll-progress.js b/examples/official-site/assets/landing/js/scroll-progress.js index c5bd27de..8130aefd 100644 --- a/examples/official-site/assets/landing/js/scroll-progress.js +++ b/examples/official-site/assets/landing/js/scroll-progress.js @@ -9,6 +9,7 @@ export function initScrollProgress(section, mount, state) { const stops = [...root.querySelectorAll("[data-scene-stop]")]; const events = new AbortController(); let frame = 0; + let scrollRest; let heroFrame; let anchors = []; let heroRange = 0; @@ -205,6 +206,15 @@ export function initScrollProgress(section, mount, state) { }); } const schedule = () => { + if (root.dataset.renderer === "baked") { + if (!root.hasAttribute("data-scrolling")) + root.setAttribute("data-scrolling", ""); + clearTimeout(scrollRest); + scrollRest = setTimeout( + () => root.removeAttribute("data-scrolling"), + 150, + ); + } if (!frame) frame = requestAnimationFrame(update); }; window.addEventListener("scroll", schedule, { @@ -220,6 +230,8 @@ export function initScrollProgress(section, mount, state) { events.abort(); observer.disconnect(); cancelAnimationFrame(frame); + clearTimeout(scrollRest); + root.removeAttribute("data-scrolling"); root.removeAttribute("data-frame-ready"); }; } diff --git a/examples/official-site/assets/landing/styles/scene.css b/examples/official-site/assets/landing/styles/scene.css index 0a7ee963..22eee11e 100644 --- a/examples/official-site/assets/landing/styles/scene.css +++ b/examples/official-site/assets/landing/styles/scene.css @@ -114,3 +114,20 @@ .viewport-footer { opacity: var(--intro-opacity); } + +/* Motion hides tiny shading differences. Reuse the finished sculpture, retain + gradients/borders, and restore the full CSS finish as soon as scrolling rests. */ +.sqlpage-world[data-renderer="baked"][data-scrolling] *, +.sqlpage-world[data-renderer="baked"][data-scrolling] *::before, +.sqlpage-world[data-renderer="baked"][data-scrolling] *::after { + filter: none; + backdrop-filter: none; + -webkit-backdrop-filter: none; + box-shadow: none; + mix-blend-mode: normal; +} + +[data-renderer="baked"] .navigation-dialog, +[data-renderer="baked"] .navigation-dialog::backdrop { + backdrop-filter: none; +} diff --git a/tests/end-to-end/landing.spec.ts b/tests/end-to-end/landing.spec.ts index 9f5fba45..e9f917df 100644 --- a/tests/end-to-end/landing.spec.ts +++ b/tests/end-to-end/landing.spec.ts @@ -10,9 +10,62 @@ for (const viewport of [ }) => { test.setTimeout(90_000); await page.setViewportSize(viewport); + // Pose tests use a virtual clock. Exercise slow graphics on the phone; + // give desktop fence completion a zero elapsed time instead of GPU timing. + await page.addInitScript((slow) => { + const gl = WebGL2RenderingContext.prototype; + const draw = gl.drawElementsInstanced; + gl.drawElementsInstanced = function ( + mode, + count, + type, + offset, + instances, + ) { + document.documentElement.dataset.testOrbInstances = String(instances); + return draw.call(this, mode, count, type, offset, instances); + }; + const viewport = gl.viewport; + gl.viewport = function (x, y, width, height) { + if (width > 500 && height > 500 && width < 1000 && height < 1000) + document.documentElement.dataset.testGlowPixels = String( + width * height, + ); + return viewport.call(this, x, y, width, height); + }; + const submit = gl.fenceSync; + gl.fenceSync = function (condition, flags) { + document.documentElement.dataset.testGpuPending = "true"; + return submit.call(this, condition, flags); + }; + gl.clientWaitSync = function () { + document.documentElement.dataset.testGpuPending = "false"; + return slow ? this.TIMEOUT_EXPIRED : this.ALREADY_SIGNALED; + }; + }, viewport.width === 390); + if (viewport.width === 1280) { + await page.route( + "**/assets/landing/js/scene/database-scene.js", + async (route) => { + const response = await route.fetch(); + await route.fulfill({ + response, + body: (await response.text()).replace( + "const elapsed = performance.now() - submittedAt;", + "const elapsed = 0;", + ), + }); + }, + ); + } await page.clock.install(); const errors: string[] = []; + const atlasRequests: string[] = []; page.on("pageerror", (error) => errors.push(error.message)); + page.on("request", (request) => { + if (request.url().includes("/sculpture-turns.")) + atlasRequests.push(request.url()); + }); await page.goto("/"); await expect(page.getByRole("heading", { level: 1 })).toContainText( "SELECT", @@ -26,7 +79,42 @@ for (const viewport of [ await settleLandingPreview(page); // Render each sampled pose without spending software GPU time on idle frames. await page.clock.pauseAt(await page.evaluate(() => Date.now() + 60_000)); - await page.clock.runFor(64); + await page.clock.runFor(viewport.width === 390 ? 1100 : 64); + await expect + .poll(async () => { + await page.clock.runFor(32); + return page.locator(".sqlpage-world").getAttribute("data-scene"); + }) + .toBe("ready"); + const canvas = page.locator("canvas"); + await expect(canvas).toHaveJSProperty( + "width", + viewport.width === 390 ? 320 : 1000, + ); + if (viewport.width === 390) { + await expect(page.locator(".sqlpage-world")).toHaveAttribute( + "data-renderer", + "baked", + ); + expect(atlasRequests).toEqual([ + expect.stringMatching(/\/sculpture-turns\.avif$/), + ]); + } else { + // Keep the full raster while batching nodes and cropping only empty glow. + await expect(page.locator("html")).toHaveAttribute( + "data-test-orb-instances", + "8", + ); + await expect(page.locator("html")).toHaveAttribute( + "data-test-glow-pixels", + /\d+/, + ); + expect( + Number( + await page.locator("html").getAttribute("data-test-glow-pixels"), + ), + ).toBeLessThanOrEqual(512_000); + } // The pinned introduction fades around a full-screen sculpture before docking. const initialSize = (await page.locator("canvas").boundingBox())!.height; @@ -66,13 +154,14 @@ for (const viewport of [ ), ) .toBeLessThan(0.005); - return page.locator("canvas").evaluate((canvas) => { + return page.locator("canvas").evaluate((canvas: HTMLCanvasElement) => { const rect = canvas.getBoundingClientRect(); return { x: rect.x + scrollX, y: rect.y + scrollY, size: rect.width, turn: Number(canvas.dataset.scrollTurn), + pixels: canvas.width === 320 ? canvas.toDataURL() : null, opacity: Number( getComputedStyle(canvas.closest(".scene-layer")!).opacity, ), @@ -147,6 +236,8 @@ for (const viewport of [ expectSamePageFrame(reading, anchor); expect(reading.opacity).toBe(1); expect(reading.turn).toBeGreaterThan(anchor.turn); + if (viewport.width === 390) + expect(reading.pixels).not.toBe(anchor.pixels); const atTop = await sample(target.top + 1); await expectAtPageAnchor(atTop, index); expect(atTop.opacity).toBe(1); @@ -189,6 +280,7 @@ for (const viewport of [ expectSamePageFrame(reverse, reading); expect(reverse.opacity).toBe(1); expect(reverse.turn).toBeLessThan(arrived.turn); + expect(reverse.pixels).toBe(reading.pixels); } // The closing sculpture must finish its entrance before the page runs out. const bottom = await sample( @@ -200,6 +292,22 @@ for (const viewport of [ expect(bottom.opacity).toBe(1); } await checkAnchors(); + if (viewport.width === 390) { + const initial = await sample(0); + await page.locator(".model-interaction").press("ArrowRight"); + expect((await sample(0)).pixels).not.toBe(initial.pixels); + await page.locator(".model-interaction").press("Home"); + expect((await sample(0)).pixels).toBe(initial.pixels); + } + if (viewport.width === 1280) { + await page.emulateMedia({ reducedMotion: "reduce" }); + await scrollLanding(page, 0); + await page.clock.runFor(64); + await expect(page.locator("html")).toHaveAttribute( + "data-test-gpu-pending", + "false", + ); + } await page.clock.resume(); expect(errors).toEqual([]); @@ -434,6 +542,10 @@ test("landing page: live components, deployment and mobile navigation", async ({ test("landing shader failure keeps the static page usable and retries cleanly", async ({ page, }) => { + // Timing is optional; failed assets must still clean up and retry without it. + await page.addInitScript(() => { + Object.defineProperty(window, "PerformanceObserver", { value: undefined }); + }); const shaderRoute = "**/assets/landing/js/scene/shaders/*.glsl"; const errors: string[] = []; page.on("pageerror", (error) => errors.push(error.message)); @@ -446,6 +558,7 @@ test("landing shader failure keeps the static page usable and retries cleanly", timeout: 30_000, }); await expect(page.locator(".scene-preview")).toBeVisible(); + await expect(page.locator("canvas")).toHaveCount(0); await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); await expect(page.locator(".model-interaction")).toHaveAttribute("inert", ""); await page.unroute(shaderRoute);