diff --git a/examples/official-site/README.md b/examples/official-site/README.md index b90357fa..39a528cb 100644 --- a/examples/official-site/README.md +++ b/examples/official-site/README.md @@ -56,6 +56,19 @@ shared, while failed loads can be retried. The model, lighting map, and static preview are versioned local assets. The official site's `sqlpage.yaml` allows jsDelivr scripts while preserving SQLPage's per-request nonce. +The studio is a textured horizontal floor with a 512 × 512 planar reflection, +filtered to 256 × 256 with the same Gaussian shader as the emission halo. +Reflection RGB and coverage are blurred together before tone mapping. Its +contact point follows the actual metal surface as the sculpture rotates. +Materials stay in `sculpture-materials.js`, lighting in `database-scene.js`, +and floor shading in the `studio-*.glsl` snippets. After changing the scene, +run the official site locally, then regenerate both fallback assets with +`node scripts/render-landing-sculpture.mjs http://127.0.0.1:8080` from the +repository root. This uses the production renderer at time zero and all 64 +turns; it requires Playwright Chromium and ImageMagick with WebP/AVIF support. +The low-power handoff retains the last live frame while its atlas decodes and +attaches the replacement only after drawing it, without showing the preview again. + Without JavaScript the document retains its content, initial live example, and navigation. CDN or WebGL failure leaves the static sculpture preview and working controls, and a retry is available for the optional scene. Reduced motion disables diff --git a/examples/official-site/assets/landing/assets/scene-first-frame.webp b/examples/official-site/assets/landing/assets/scene-first-frame.webp index 9d3acb64..e3854447 100644 Binary files a/examples/official-site/assets/landing/assets/scene-first-frame.webp and b/examples/official-site/assets/landing/assets/scene-first-frame.webp differ diff --git a/examples/official-site/assets/landing/assets/sculpture-turns.avif b/examples/official-site/assets/landing/assets/sculpture-turns.avif index e95bac6e..4895eec8 100644 Binary files a/examples/official-site/assets/landing/assets/sculpture-turns.avif and b/examples/official-site/assets/landing/assets/sculpture-turns.avif differ 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 93672cbf..d8e0e8f9 100644 --- a/examples/official-site/assets/landing/js/scene/database-scene.js +++ b/examples/official-site/assets/landing/js/scene/database-scene.js @@ -54,7 +54,8 @@ export function createDatabaseScene({ contextLost = false, frame = 0, last = 0, - time = 0; + time = 0, + renderedFrames = 0; let renderedZoom = 0, visible = document.visibilityState !== "hidden", loaded = false, @@ -91,7 +92,7 @@ export function createDatabaseScene({ renderer.setClearColor(0x000000, 0); renderer.shadowMap.enabled = true; renderer.shadowMap.autoUpdate = false; - renderer.shadowMap.type = THREE.PCFSoftShadowMap; + renderer.shadowMap.type = THREE.PCFShadowMap; let canvas = renderer.domElement; const canvasStyle = "position:absolute;left:0;top:0;width:1000px;height:1000px;transform-origin:0 0"; @@ -107,7 +108,8 @@ export function createDatabaseScene({ samples = 0, slowFrames = 0, cpuBusyUntil = 0; - let baked, context, lastTurn; + let baked, context, lastTurn, pendingAtlas, previousCanvas; + let switchingRenderer = false; const resetBudget = () => { if (fence) gl.deleteSync(fence); fence = undefined; @@ -139,33 +141,33 @@ export function createDatabaseScene({ renderer.forceContextLoss(); }; const useBakedViews = () => { - releaseGraphics(); - baked = new Image(); + if (switchingRenderer) return; + switchingRenderer = true; + const atlas = new Image(); + pendingAtlas = atlas; const replacement = document.createElement("canvas"); replacement.width = 400; replacement.height = 400; - context = replacement.getContext("2d"); - if (!context) { + const replacementContext = replacement.getContext("2d"); + if (!replacementContext) { onError(); return; } - canvas.replaceWith(replacement); - canvas = replacement; - canvas.style.cssText = canvasStyle; - 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( + atlas.src = new URL( "../../assets/sculpture-turns.avif", import.meta.url, ).href; - baked.decode().then( + atlas.decode().then( () => { - loaded = !disposed; + if (disposed) return; + // Keep the last submitted WebGL frame visible during download/decode. + // The replacement is attached only after its first atlas view is drawn. + baked = atlas; + context = replacementContext; + previousCanvas = canvas; + canvas = replacement; + canvas.style.cssText = canvasStyle; + switchingRenderer = false; needsRender = true; }, () => { @@ -180,25 +182,26 @@ export function createDatabaseScene({ const interior = { uInteriorPosition: { value: new THREE.Vector3() }, uInteriorRadius: { value: 1 }, - uInteriorPulse: { value: 14 }, + uInteriorPulse: { value: 2.4 }, }; - const interiorLight = new THREE.PointLight(0x72c8de, 0.6, 2.1, 2); + const interiorLight = new THREE.PointLight(0x8bb8e8, 0.4, 2.1, 2); interiorLight.name = "Soft interior database light"; const interiorScale = new THREE.Vector3(); let glow, jewelReflection; - const key = new THREE.DirectionalLight(0xe5edf1, 1.7); - key.position.set(-3.5, 5, 5); + const key = new THREE.DirectionalLight(0xd8e9fa, 1.2); + key.position.set(-3, 5, 2.5); key.castShadow = true; - key.shadow.mapSize.set(512, 512); + key.shadow.mapSize.set(1024, 1024); Object.assign(key.shadow.camera, { - left: -4, - right: 4, - top: 4, - bottom: -4, + left: -3, + right: 3, + top: 3, + bottom: -3, near: 0.5, far: 18, }); - key.shadow.normalBias = 0.025; + key.shadow.normalBias = 0.012; + key.shadow.radius = 8; scene.add(key); const rim = new THREE.DirectionalLight(0xa1bac7, 0.8); rim.position.set(4, 2, -2); @@ -208,12 +211,12 @@ export function createDatabaseScene({ scene.add(fill); RectAreaLightUniformsLib.init(); - const softbox = new THREE.RectAreaLight(0xe5edf1, 7, 2.5, 6); - softbox.position.set(-3.5, 1.5, 4); + const softbox = new THREE.RectAreaLight(0xe5eff9, 9, 1.5, 6); + softbox.position.set(-3, 2, 4.5); softbox.lookAt(0, 0, 0); scene.add(softbox); - const edgeSoftbox = new THREE.RectAreaLight(0xa1bac7, 4, 1.2, 5); - edgeSoftbox.position.set(3.5, 1.5, 2); + const edgeSoftbox = new THREE.RectAreaLight(0xa9cbec, 3, 0.7, 4); + edgeSoftbox.position.set(3.5, 2, 3.5); edgeSoftbox.lookAt(0, 0, 0); scene.add(edgeSoftbox); scene.add(new THREE.HemisphereLight(0xa1bac7, 0x18262d, 0.55)); @@ -242,7 +245,7 @@ export function createDatabaseScene({ ); glow.setSize(1000, 1000, 1); jewelReflection = createFiniteJewelLight(JEWEL_EDGES, shaders); - studioSet = createSculptureStudio(); + studioSet = createSculptureStudio(studio, shaders); scene.add(studioSet.group); performance.mark("sqlpage:assets-ready"); const bounds = new THREE.Box3().setFromObject(gltf.scene); @@ -271,17 +274,24 @@ export function createDatabaseScene({ if (material.name.includes("Separator")) finishLiquid(material, liquidClock, studio, interior, shaders); if (material.name.includes("Jewel Wire")) { - material.emissiveIntensity = 1.05; + material.color.set("#a9d2fa"); + material.emissive.set("#8bb8e8"); + material.emissiveIntensity = 1.6; wireMaterial = material; } - if (material.name.includes("Endpoint")) - material.emissiveIntensity = 1.2; + if (material.name.includes("Endpoint")) { + material.color.set("#c9e2fb"); + material.emissive.set("#a9d2fa"); + material.emissiveIntensity = 1.8; + } + if (material.name.includes("Facets")) { + material.color.set("#8bb8e8"); + material.emissive.set("#466689"); + material.opacity = 0.035; + } if (material.transparent) material.depthWrite = false; } }); - const jewelLight = new THREE.PointLight(0x72c8de, 0.09, 2.4, 2); - jewelLight.position.set(0, 1.35, 0); - gltf.scene.add(jewelLight); // Native model coordinates: stay inside the radius-1.04 database stack. interiorLight.position.set(0.4, -0.45, 0); gltf.scene.add(interiorLight); @@ -428,6 +438,7 @@ export function createDatabaseScene({ } if ( loaded && + !switchingRenderer && (inView || !readySent) && (state.motion || needsRender || changing) ) { @@ -461,6 +472,15 @@ export function createDatabaseScene({ } lastTurn = turn; } + if (previousCanvas) { + previousCanvas.replaceWith(canvas); + previousCanvas = undefined; + releaseGraphics(); + root.dataset.renderer = "baked"; + 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."; + } } else { // Prepare GPU-only animation only when a new live frame can be drawn. liquidClock.value = time; @@ -473,9 +493,9 @@ export function createDatabaseScene({ -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; + const pulse = 2.4 + Math.sin(time * ((Math.PI * 2) / 6)) * 0.3; interior.uInteriorPulse.value = pulse; - interiorLight.intensity = (0.6 * pulse) / 14; + interiorLight.intensity = (0.4 * pulse) / 2.4; scene.updateMatrixWorld(true); studioSet.update(model); scene.updateMatrixWorld(true); @@ -485,15 +505,19 @@ export function createDatabaseScene({ if (diamond && wireMaterial) jewelReflection.update(diamond, camera, wireMaterial); syncHitArea(); + studioSet.renderReflection(renderer, scene, camera); renderer.shadowMap.needsUpdate = true; renderer.render(scene, camera); glow.render(model, [studioSet.group]); fence = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0); submittedAt = performance.now(); + renderedFrames++; } - needsRender = false; + // The reflection receiver settles on the second submitted frame. Keep + // the matching preview visible and time frozen throughout initialization. + needsRender = !baked && renderedFrames < 2; lastOrientation.copy(dragQuaternion); - if (!readySent) { + if (!readySent && (baked || renderedFrames >= 2)) { readySent = true; performance.mark("sqlpage:first-frame"); performance.measure( @@ -530,7 +554,9 @@ export function createDatabaseScene({ timing?.disconnect(); if (instructions) instructions.textContent = originalInstructions; if (baked) baked.src = ""; - if (!baked) releaseGraphics(); + if (pendingAtlas) pendingAtlas.src = ""; + if (!baked || previousCanvas) releaseGraphics(); + previousCanvas?.remove(); canvas.remove(); }, }; diff --git a/examples/official-site/assets/landing/js/scene/landing-frame.js b/examples/official-site/assets/landing/js/scene/landing-frame.js index e4543002..45bd2a04 100644 --- a/examples/official-site/assets/landing/js/scene/landing-frame.js +++ b/examples/official-site/assets/landing/js/scene/landing-frame.js @@ -20,7 +20,7 @@ function frameAt(width, height, anchor, portrait, bodyWidth = width * 0.48) { const compositionLeft = (width - compositionWidth) / 2; const desiredWidth = portrait ? bodyWidth - : Math.min(compositionWidth * 0.335, height * 0.59); + : Math.min(compositionWidth * 0.26, height * 0.47); let size = desiredWidth / SCULPTURE_FRAME.bodyWidth; const align = () => { const left = anchor.x - SCULPTURE_FRAME.shoulder.x * size; @@ -60,6 +60,7 @@ function frameAt(width, height, anchor, portrait, bodyWidth = width * 0.48) { export function layoutLandingFrame(viewport, anchor) { const width = Math.max(1, viewport.clientWidth); const screenHeight = window.innerHeight; + const compositionWidth = Math.min(width, screenHeight * 1.95); const portrait = width < 900 && screenHeight >= width; const shortLandscape = !portrait && screenHeight < 560; const section = viewport.parentElement; @@ -108,13 +109,16 @@ export function layoutLandingFrame(viewport, anchor) { rect.left - surface.left + (ink.actualBoundingBoxRight || rect.width) - - fontSize * 0.045, + fontSize * 0.045 + + // Leave a clear gutter between the compact headline and sculpture. + compositionWidth * 0.2, y: rect.bottom - surface.top - (ink.fontBoundingBoxDescent ?? fontSize * 0.2) + ink.actualBoundingBoxDescent - - fontSize * 0.025, + fontSize * 0.025 - + height * 0.025, }; }; if (portrait) diff --git a/examples/official-site/assets/landing/js/scene/sculpture-materials.js b/examples/official-site/assets/landing/js/scene/sculpture-materials.js index 87c4c2bc..c8f9cfe3 100644 --- a/examples/official-site/assets/landing/js/scene/sculpture-materials.js +++ b/examples/official-site/assets/landing/js/scene/sculpture-materials.js @@ -31,14 +31,13 @@ function localPosition(shader, varying) { export function finishMetal(material, environment, shaders) { const lid = material.name.includes("Mirror Top"); - const rim = material.name.includes("Gunmetal"); - material.color.set(lid ? "#384651" : rim ? "#adb8c2" : "#9ca8b2"); - material.metalness = lid ? 0.96 : 0.9; - // Keep the cap polished: the satin shell roughness blurs the reflected wires. - material.roughness = lid ? 0.09 : rim ? 0.24 : 0.3; + // One dark satin metal across the lid, shell and rims. Surface orientation + // and machining direction shape the highlights, rather than different alloys. + material.color.set("#4a5a68"); + material.metalness = 1; + material.roughness = 0.34; material.envMap = environment; - material.envMapIntensity = - (lid ? 0.82 : rim ? 1.15 : 1.08) * STUDIO_RADIANCE_SCALE; + material.envMapIntensity = 0.6 * STUDIO_RADIANCE_SCALE; material.defines = { ...material.defines, METAL_LID: lid ? 1 : 0 }; material.onBeforeCompile = (shader) => { localPosition(shader, "vMetalPosition"); @@ -48,16 +47,16 @@ export function finishMetal(material, environment, shaders) { ); }; material.customProgramCacheKey = () => - `sqlpage-black-metal-${lid ? "lid" : "shell"}-v5`; + `sqlpage-satin-metal-${lid ? "lid" : "shell"}-v7`; } export function finishLiquid(material, clock, environment, interior, shaders) { const rim = material.name.includes("Rims"); - material.color.set(rim ? "#133340" : "#06131c"); - material.emissive.set(rim ? "#246582" : "#14638a"); - material.emissiveIntensity = rim ? 0.28 : 0.55; - material.metalness = 0.2; - material.roughness = rim ? 0.32 : 0.25; + material.color.set(rim ? "#527ba5" : "#345576"); + material.emissive.set("#719fe8"); + material.emissiveIntensity = rim ? 0.45 : 0.65; + material.metalness = 0.25; + material.roughness = rim ? 0.23 : 0.18; material.envMap = environment; material.envMapIntensity = 0.18 * STUDIO_RADIANCE_SCALE; material.transparent = false; diff --git a/examples/official-site/assets/landing/js/scene/sculpture-studio.js b/examples/official-site/assets/landing/js/scene/sculpture-studio.js index 8f6ddb1b..a8ea1b52 100644 --- a/examples/official-site/assets/landing/js/scene/sculpture-studio.js +++ b/examples/official-site/assets/landing/js/scene/sculpture-studio.js @@ -1,94 +1,154 @@ -/** A matte studio sweep catches real light and the sculpture's shadow. */ +/** A horizontal studio floor, lit by the same softboxes as the sculpture. */ import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.186.1/+esm"; +import { Reflector } from "https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/objects/Reflector.js/+esm"; +import { FullScreenQuad } from "https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/postprocessing/Pass.js/+esm"; +import { STUDIO_RADIANCE_SCALE } from "./sculpture-assets.js"; -export function createSculptureStudio() { +export function createSculptureStudio(environment, shaders) { const group = new THREE.Group(); - const profile = [ - [5, 0], - [-1.8, 0], - ]; - for (let i = 1; i <= 20; i++) { - const angle = ((i / 20) * Math.PI) / 2; - profile.push([-1.8 - Math.sin(angle) * 1.2, 1.2 - Math.cos(angle) * 1.2]); - } - profile.push([-3, 7]); - const positions = [], - indices = []; - profile.forEach(([z, y], i) => { - positions.push(-6, y, z, 6, y, z); - if (i) { - const n = i * 2; - indices.push(n - 2, n - 1, n, n - 1, n + 1, n); - } + const geometry = new THREE.PlaneGeometry(20, 20); + // The reflected camera renders at 512 × 512; the floor filters + // that linear HDR image before the main scene's single tone-mapping pass. + const reflection = new Reflector(geometry, { + textureWidth: 512, + textureHeight: 512, + multisample: 0, + clipBias: 0.002, }); - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute( - "position", - new THREE.Float32BufferAttribute(positions, 3), + reflection.rotation.x = -Math.PI / 2; + reflection.visible = false; + group.add(reflection); + const blurred = Array.from( + { length: 2 }, + () => + new THREE.WebGLRenderTarget(256, 256, { + type: THREE.HalfFloatType, + depthBuffer: false, + }), ); - geometry.setIndex(indices); - geometry.computeVertexNormals(); - const theme = getComputedStyle(document.documentElement); + const blur = new THREE.ShaderMaterial({ + uniforms: { + tSource: { value: reflection.getRenderTarget().texture }, + uStep: { value: new THREE.Vector2() }, + uExtract: { value: 0 }, + }, + vertexShader: shaders.fullscreenVertex, + fragmentShader: shaders.blurFragment, + depthTest: false, + depthWrite: false, + toneMapped: false, + }); + const blurQuad = new FullScreenQuad(blur); + const contact = { value: new THREE.Vector2() }; const material = new THREE.MeshStandardMaterial({ - color: theme.getPropertyValue("--tblr-bg-surface-tertiary").trim(), - roughness: 0.86, - metalness: 0.12, + color: "#1c2932", + roughness: 0.48, + metalness: 0.3, + envMap: environment, + envMapIntensity: 0.35 * STUDIO_RADIANCE_SCALE, transparent: true, depthWrite: false, - opacity: 0.65, }); material.onBeforeCompile = (shader) => { + shader.uniforms.uStudioContact = contact; + shader.uniforms.uFloorReflection = { value: blurred[1].texture }; + shader.uniforms.uFloorReflectionMatrix = + reflection.material.uniforms.textureMatrix; shader.vertexShader = shader.vertexShader .replace( "#include ", - "#include \nvarying vec3 vStudioPosition;", + "#include \nvarying vec3 vStudioPosition;\nvarying vec4 vFloorReflection;\nuniform mat4 uFloorReflectionMatrix;", ) .replace( "#include ", - "#include \nvStudioPosition = position;", + "#include \nvStudioPosition = vec3(position.x, 0.0, -position.y);\nvFloorReflection = uFloorReflectionMatrix * vec4(position, 1.0);", ); shader.fragmentShader = shader.fragmentShader .replace( "#include ", - "#include \nvarying vec3 vStudioPosition;", + `#include \nvarying vec3 vStudioPosition;\nuniform vec2 uStudioContact;\nvarying vec4 vFloorReflection;\nuniform sampler2D uFloorReflection;\n${shaders.studioNoise}`, ) - .replace( - "#include ", - `#include - float edge = 1.0 - smoothstep(1.2, 2.8, abs(vStudioPosition.x)); - float wall = 1.0 - smoothstep(1.8, 5.0, vStudioPosition.y); - float floorEdge = 1.0 - smoothstep(1.0, 3.8, vStudioPosition.z); - float grain = fract(sin(dot(floor(vStudioPosition.xz * 800.0), vec2(12.9898, 78.233))) * 43758.5453); - diffuseColor.rgb *= 0.94 + grain * 0.12; - diffuseColor.a *= edge * wall * floorEdge; - `, - ); + .replace("#include ", shaders.studioFinish) + .replace("#include ", shaders.studioNormal) + .replace("#include ", shaders.studioReflection); }; - material.customProgramCacheKey = () => "sqlpage-studio-sweep-v1"; - const sweep = new THREE.Mesh(geometry, material); - sweep.receiveShadow = true; - group.add(sweep); - const spill = new THREE.PointLight( - theme.getPropertyValue("--tblr-primary").trim(), - 2.4, - 5, - 2, - ); - spill.position.set(0.3, 0.85, -0.6); + material.customProgramCacheKey = () => "sqlpage-studio-floor-v3"; + const floor = new THREE.Mesh(geometry, material); + floor.rotation.x = -Math.PI / 2; + floor.receiveShadow = true; + group.add(floor); + + // Local blue spill grazes the floor, revealing its satin texture. It stays + // in the room when the model turns; there is no illuminated vertical wall. + const spill = new THREE.RectAreaLight(0xc3ddf8, 5, 3, 1.5); + spill.position.set(-1.5, 2, -1.3); + spill.lookAt(0, 0, 0.2); group.add(spill); - const bounds = new THREE.Box3(); + const edge = new THREE.RectAreaLight(0x8abaf0, 2.5, 2, 2); + edge.position.set(1.8, 2, -1.5); + edge.lookAt(0, 0, 0.2); + group.add(edge); + + const meshes = []; + const previous = new THREE.Matrix4().multiplyScalar(0); + const point = new THREE.Vector3(); + const lowest = new THREE.Vector3(); return { group, dispose() { geometry.dispose(); material.dispose(); + reflection.dispose(); + blurred.forEach((target) => { + target.dispose(); + }); + blur.dispose(); + blurQuad.dispose(); group.removeFromParent(); }, + renderReflection(renderer, scene, camera) { + const savedTarget = renderer.getRenderTarget(); + floor.visible = false; + try { + reflection.onBeforeRender(renderer, scene, camera); + // A continuous Gaussian footprint avoids repeated, offset copies of + // the luminous bands. RGB and coverage are filtered together. + blur.uniforms.tSource.value = reflection.getRenderTarget().texture; + blur.uniforms.uStep.value.set(1.2 / 512, 0); + renderer.setRenderTarget(blurred[0]); + blurQuad.render(renderer); + blur.uniforms.tSource.value = blurred[0].texture; + blur.uniforms.uStep.value.set(0, 0.8 / 256); + renderer.setRenderTarget(blurred[1]); + blurQuad.render(renderer); + } finally { + renderer.setRenderTarget(savedTarget); + floor.visible = true; + reflection.visible = false; + } + }, update(model) { - // Follow the lowest point of the tilted/dragged model without rotating - // the room with it. The shadow remains attached to the contact surface. - bounds.setFromObject(model); - group.position.y = bounds.min.y - 0.025; + if (previous.equals(model.matrixWorld)) return; + previous.copy(model.matrixWorld); + if (!meshes.length) + model.traverse((mesh) => { + if (mesh.isMesh && mesh.castShadow) meshes.push(mesh); + }); + // A transformed bounding box includes empty corners below a tilted + // cylinder. Use its actual surface so the sculpture touches the floor, + // including after a drag, without rotating the floor with the object. + lowest.set(0, Infinity, 0); + for (const mesh of meshes) { + const positions = mesh.geometry.attributes.position; + for (let i = 0; i < positions.count; i++) { + point + .fromBufferAttribute(positions, i) + .applyMatrix4(mesh.matrixWorld); + if (point.y < lowest.y) lowest.copy(point); + } + } + group.position.y = lowest.y - 0.005; + contact.value.set(lowest.x, lowest.z); }, }; } 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 2e7750ae..a2b22cce 100644 --- a/examples/official-site/assets/landing/js/scene/selective-glow.js +++ b/examples/official-site/assets/landing/js/scene/selective-glow.js @@ -49,7 +49,7 @@ export function createSelectiveGlow( uEmission: { value: original.emissive .clone() - .multiplyScalar(original.emissiveIntensity * (liquid ? 3 : 1)), + .multiplyScalar(original.emissiveIntensity), }, uLiquid: { value: liquid ? 1 : 0 }, uLiquidTime: clock, diff --git a/examples/official-site/assets/landing/js/scene/shader-sources.js b/examples/official-site/assets/landing/js/scene/shader-sources.js index d8ba4e19..2acdaf7a 100644 --- a/examples/official-site/assets/landing/js/scene/shader-sources.js +++ b/examples/official-site/assets/landing/js/scene/shader-sources.js @@ -12,6 +12,10 @@ const files = { liquidFinish: "liquid-finish.glsl", liquidFlow: "liquid-flow.glsl", metalFinish: "metal-finish.glsl", + studioFinish: "studio-finish.glsl", + studioNormal: "studio-normal.glsl", + studioNoise: "studio-noise.glsl", + studioReflection: "studio-reflection.glsl", }; let sources; diff --git a/examples/official-site/assets/landing/js/scene/shaders/blur-fragment.glsl b/examples/official-site/assets/landing/js/scene/shaders/blur-fragment.glsl index 5af6ca5c..0fe453f8 100644 --- a/examples/official-site/assets/landing/js/scene/shaders/blur-fragment.glsl +++ b/examples/official-site/assets/landing/js/scene/shaders/blur-fragment.glsl @@ -3,16 +3,16 @@ uniform sampler2D tSource; uniform vec2 uStep; uniform float uExtract; varying vec2 vUv; -vec3 sampleLight(vec2 uv) { - vec3 c = texture2D(tSource, uv).rgb; - float luminance = dot(c, vec3(.2126, .7152, .0722)); +vec4 sampleLight(vec2 uv) { + vec4 c = texture2D(tSource, uv); + float luminance = dot(c.rgb, vec3(.2126, .7152, .0722)); return c * mix(1.0, smoothstep(.045, .065, luminance), uExtract); } void main() { - vec3 light = sampleLight(vUv) * .2270270270; + vec4 light = sampleLight(vUv) * .2270270270; light += (sampleLight(vUv + uStep * 1.3846153846) + sampleLight(vUv - uStep * 1.3846153846)) * .3162162162; light += (sampleLight(vUv + uStep * 3.2307692308) + sampleLight(vUv - uStep * 3.2307692308)) * .0702702703; - gl_FragColor = vec4(light, 1.0); + gl_FragColor = light; } diff --git a/examples/official-site/assets/landing/js/scene/shaders/composite-fragment.glsl b/examples/official-site/assets/landing/js/scene/shaders/composite-fragment.glsl index 3f2cabb8..2ec4e679 100644 --- a/examples/official-site/assets/landing/js/scene/shaders/composite-fragment.glsl +++ b/examples/official-site/assets/landing/js/scene/shaders/composite-fragment.glsl @@ -4,8 +4,8 @@ uniform sampler2D tWide; varying vec2 vUv; void main() { if (any(lessThan(vUv, vec2(0.0))) || any(greaterThan(vUv, vec2(1.0)))) discard; - vec3 halo = texture2D(tNarrow, vUv).rgb * .64 - + texture2D(tWide, vUv).rgb * .16; + vec3 halo = texture2D(tNarrow, vUv).rgb * .4 + + texture2D(tWide, vUv).rgb * .1; gl_FragColor = vec4(max(halo, vec3(0.0)), 1.0); #include #include diff --git a/examples/official-site/assets/landing/js/scene/shaders/jewel-reflection.glsl b/examples/official-site/assets/landing/js/scene/shaders/jewel-reflection.glsl index 5714b7bf..bacba078 100644 --- a/examples/official-site/assets/landing/js/scene/shaders/jewel-reflection.glsl +++ b/examples/official-site/assets/landing/js/scene/shaders/jewel-reflection.glsl @@ -6,9 +6,8 @@ vec3 jewelRay = reflect(-geometryViewDir, geometryNormal); // dominates at normal viewing sizes; only distant pixels are widened. float jewelPixel = max(length(dFdx(jewelRay)), length(dFdy(jewelRay))); float jewelAlpha = sqrt(pow(material.roughness, 4.0) + .25 * jewelPixel * jewelPixel); -// Alpha is roughness squared. A .012 floor would turn the -// polished .09 cap back into a .11-roughness surface. Retain -// its mirror image while the derivative term filters distant pixels. +// Retain the material's roughness. The lower bound only protects the +// finite-emitter approximation as the angular footprint approaches zero. jewelAlpha = clamp(jewelAlpha, .0036, 1.0); PhysicalMaterial jewelSurface = material; jewelSurface.roughness = sqrt(jewelAlpha); diff --git a/examples/official-site/assets/landing/js/scene/shaders/metal-finish.glsl b/examples/official-site/assets/landing/js/scene/shaders/metal-finish.glsl index 091a3501..f51b7290 100644 --- a/examples/official-site/assets/landing/js/scene/shaders/metal-finish.glsl +++ b/examples/official-site/assets/landing/js/scene/shaders/metal-finish.glsl @@ -2,19 +2,13 @@ #include #if METAL_LID float metalToolCoordinate = length(vMetalPosition.xz); - const float metalMachining = .003; - const float metalGrainStrength = .004; - const float metalMinimumRoughness = .06; #else float metalToolCoordinate = vMetalPosition.y; - const float metalMachining = .02; - const float metalGrainStrength = .026; - const float metalMinimumRoughness = .15; #endif float metalGrain = fract(sin(dot( floor(vMetalPosition * vec3(190.0, 1250.0, 190.0)), vec3(12.9898, 78.233, 43.128))) * 43758.5453); float toolPath = metalToolCoordinate * 760.0; float machining = sin(toolPath) * (1.0 - smoothstep(.6, 3.0, fwidth(toolPath))); -roughnessFactor = clamp(roughnessFactor + machining * metalMachining - + (metalGrain - .5) * metalGrainStrength, metalMinimumRoughness, .48); +roughnessFactor = clamp(roughnessFactor + machining * .006 + + (metalGrain - .5) * .012, .29, .39); diff --git a/examples/official-site/assets/landing/js/scene/shaders/studio-finish.glsl b/examples/official-site/assets/landing/js/scene/shaders/studio-finish.glsl new file mode 100644 index 00000000..07a9b108 --- /dev/null +++ b/examples/official-site/assets/landing/js/scene/shaders/studio-finish.glsl @@ -0,0 +1,24 @@ +// PBR stone: irregular roughness and relief catch the grazing softboxes. +#include +vec2 floorPoint = vStudioPosition.xz; +vec2 floorUV = floorPoint * 220.0; +vec2 footprint = fwidth(floorUV); +float detail = 1.0 - smoothstep(.5, 2.0, max(footprint.x, footprint.y)); +float grain = studioNoise(floorUV); +float coarseGrain = studioNoise(floorPoint * 75.0); +roughnessFactor = clamp(roughnessFactor + (coarseGrain - .5) * .16, .32, .6); +diffuseColor.rgb *= .9 + coarseGrain * .15 + grain * detail * .05; +vec2 contactOffset = (floorPoint - uStudioContact) / vec2(.48, .38); +float contactShadow = exp(-dot(contactOffset, contactOffset) * 2.0); +diffuseColor.rgb *= 1.0 - contactShadow * .85; +// A broad horizontal surface remains visible around and behind the sculpture. +// Its distant edges blend into the page without fading away the contact area. +vec2 floorOffset = (floorPoint - uStudioContact * .35) / vec2(4.2, 5.5); +float floorRadius = length(floorOffset); +diffuseColor.a *= 1.0 - smoothstep(.38, 1.0, floorRadius); +// This square renderer moves between page anchors. Fade before its boundary, +// so a broad ground plane never exposes a rectangular crop on the page. +vec2 floorScreen = gl_FragCoord.xy / 1000.0; +vec2 screenGround = (floorScreen - vec2(.49, .17)) / vec2(.5, .3); +diffuseColor.a *= (1.0 - smoothstep(.25, 1.0, length(screenGround))) + * smoothstep(0.0, .12, floorScreen.y); diff --git a/examples/official-site/assets/landing/js/scene/shaders/studio-noise.glsl b/examples/official-site/assets/landing/js/scene/shaders/studio-noise.glsl new file mode 100644 index 00000000..5b9eca08 --- /dev/null +++ b/examples/official-site/assets/landing/js/scene/shaders/studio-noise.glsl @@ -0,0 +1,12 @@ +// Stable value noise gives the studio surface an irregular, non-periodic grain. +float studioHash(vec2 p) { + vec3 q = fract(vec3(p.xyx) * .1031); + q += dot(q, q.yzx + 33.33); + return fract((q.x + q.y) * q.z); +} +float studioNoise(vec2 p) { + vec2 i = floor(p), f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix(mix(studioHash(i), studioHash(i + vec2(1, 0)), f.x), + mix(studioHash(i + vec2(0, 1)), studioHash(i + vec2(1, 1)), f.x), f.y); +} diff --git a/examples/official-site/assets/landing/js/scene/shaders/studio-normal.glsl b/examples/official-site/assets/landing/js/scene/shaders/studio-normal.glsl new file mode 100644 index 00000000..cfcfe347 --- /dev/null +++ b/examples/official-site/assets/landing/js/scene/shaders/studio-normal.glsl @@ -0,0 +1,12 @@ +// A tiny surface relief scatters the grazing studio light. Derivatives filter +// the same grain used for roughness, avoiding a second texture or noise field. +#include +float floorHeight = (grain * detail + coarseGrain * .5) * .0004; +vec3 floorDx = dFdx(-vViewPosition); +vec3 floorDy = dFdy(-vViewPosition); +vec3 floorR1 = cross(floorDy, normal); +vec3 floorR2 = cross(normal, floorDx); +float floorDet = dot(floorDx, floorR1); +vec3 floorGradient = sign(floorDet) + * (dFdx(floorHeight) * floorR1 + dFdy(floorHeight) * floorR2); +normal = normalize(max(abs(floorDet), 1e-10) * normal - floorGradient); diff --git a/examples/official-site/assets/landing/js/scene/shaders/studio-reflection.glsl b/examples/official-site/assets/landing/js/scene/shaders/studio-reflection.glsl new file mode 100644 index 00000000..a0cf26a9 --- /dev/null +++ b/examples/official-site/assets/landing/js/scene/shaders/studio-reflection.glsl @@ -0,0 +1,10 @@ +// Rough planar reflection of the actual sculpture, in linear light. The +// Gaussian filtering and surface relief soften the blue bands on stone. +vec2 reflectedUV = vFloorReflection.xy / vFloorReflection.w; +reflectedUV += (vec2(grain, coarseGrain) - .5) * .002; +vec4 floorReflection = texture2D(uFloorReflection, reflectedUV); +float reflectionWeight = .48 * floorReflection.a; +outgoingLight = outgoingLight * (1.0 - reflectionWeight) + + floorReflection.rgb * .48; +outgoingLight *= 1.0 - contactShadow * .85; +#include diff --git a/examples/official-site/assets/landing/js/scroll-progress.js b/examples/official-site/assets/landing/js/scroll-progress.js index e9a0a42c..dffdce27 100644 --- a/examples/official-site/assets/landing/js/scroll-progress.js +++ b/examples/official-site/assets/landing/js/scroll-progress.js @@ -141,7 +141,10 @@ export function initScrollProgress(section, state) { dock(viewport); state.frame = heroFrame; } else if (heroRange && scroll <= heroDockScroll) { - dock(root); + // Keep opening controls above the floor until they have faded out. + // Later flight frames use viewport coordinates in the root host. + const inHero = p < 0.5; + dock(inHero ? viewport : root); const bounds = SCULPTURE_FRAME.bounds; const size = Math.min( (innerWidth * 0.9) / SCULPTURE_FRAME.bodyWidth, @@ -178,6 +181,11 @@ export function initScrollProgress(section, state) { state.frame.top += Math.sin(t * Math.PI) ** 2 * innerHeight * 0.06; state.frame.top -= scroll; } + if (inHero) { + const rect = viewport.getBoundingClientRect(); + state.frame.left -= rect.left; + state.frame.top -= rect.top; + } } else { let index = 0; while ( diff --git a/examples/official-site/assets/landing/styles/hero.css b/examples/official-site/assets/landing/styles/hero.css index b9b3240d..0b69c667 100644 --- a/examples/official-site/assets/landing/styles/hero.css +++ b/examples/official-site/assets/landing/styles/hero.css @@ -2,90 +2,43 @@ .engraved-comment { position: absolute; right: calc(var(--composition-left) + var(--composition-width) * 0.045); - top: 16%; + top: 12%; z-index: 21; - width: clamp(248px, 18vw, 330px); + width: clamp(224px, 15vw, 260px); opacity: var(--intro-opacity); transform: translateX(calc(var(--progress) * 30px)); pointer-events: none; } .metal-recess { - position: relative; - overflow: hidden; - padding: 19px 24px 20px; - border: 1px solid color-mix(in srgb, var(--tblr-primary) 20.00%, transparent); - border-radius: 6px; - background: - repeating-linear-gradient( - 0deg, - color-mix(in srgb, var(--tblr-gray-300) 0.39%, transparent) 0, - color-mix(in srgb, var(--tblr-gray-300) 0.39%, transparent) 1px, - transparent 1px, - transparent 3px - ), - linear-gradient( - 135deg, - color-mix(in srgb, var(--tblr-body-bg), var(--tblr-black) 70%), - var(--tblr-body-bg) 72%, - var(--tblr-body-bg) - ); - box-shadow: - inset 0 5px 9px color-mix(in srgb, var(--tblr-black) 73.33%, transparent), - inset 3px 0 6px color-mix(in srgb, var(--tblr-black) 46.67%, transparent), - inset -1px -1px 1px - color-mix(in srgb, var(--tblr-muted-color) 8.24%, transparent), - 0 1px 0 color-mix(in srgb, var(--tblr-muted-color) 10.59%, transparent); -} -.metal-recess:before { - content: ""; - position: absolute; - inset: 0; - border-radius: inherit; - box-shadow: - inset 0 1px 1px var(--tblr-black), - inset 0 -1px 0 - color-mix(in srgb, var(--tblr-secondary-color) 7.45%, transparent); - pointer-events: none; -} -.metal-recess:after { - content: ""; - position: absolute; - inset: 2px; - border-radius: 2px; - border-top: 1px solid - color-mix(in srgb, var(--tblr-black) 46.67%, transparent); - border-left: 1px solid - color-mix(in srgb, var(--tblr-black) 20.00%, transparent); - pointer-events: none; + padding: 16px 24px; + border: 1px solid color-mix(in srgb, var(--tblr-primary) 28%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--tblr-body-bg) 58%, transparent); } .metal-recess code { - position: relative; display: grid; gap: 6px; - color: var(--tblr-muted-color); - font: clamp(13px, 0.82vw, 16px) / 1.65 var(--tblr-font-monospace); + color: var(--tblr-secondary-color); + font: clamp(13px, 0.95vw, 16px) / 1.65 var(--tblr-font-monospace); letter-spacing: -0.025em; - text-shadow: - 0 -1px 1px var(--tblr-black), - 0 1px 0 color-mix(in srgb, var(--tblr-gray-300) 10.59%, transparent); } .metal-recess code b { font-weight: 400; - color: color-mix(in srgb, var(--tblr-dark), var(--tblr-white) 40%); + color: var(--tblr-primary); } .hero-copy { z-index: 21; position: absolute; - top: 13%; - left: calc(var(--composition-left) + var(--composition-width) * 0.042); - width: calc(var(--composition-width) * 0.608); + top: 20.5%; + left: calc(var(--composition-left) + var(--composition-width) * 0.07); + width: calc(var(--composition-width) * 0.45); pointer-events: none; } .hero-copy h1 { margin: 0; display: flex; flex-direction: column; - font-size: clamp(88px, min(11.9vw, 17.5svh), 288px); + font-size: clamp(64px, min(7.1vw, 11.5svh), 160px); line-height: 0.94; letter-spacing: 0; font-weight: 800; @@ -108,43 +61,21 @@ } .hero-copy .title-website { z-index: 4; - align-self: flex-end; - margin-inline-end: -0.105em; + align-self: flex-start; + margin-inline-end: 0; } .hero-copy .title-from { - align-self: flex-end; - margin-right: calc(var(--composition-width) * 0.045); + align-self: flex-start; + margin-right: 0; } .title-select, .title-from { color: var(--tblr-light); - text-shadow: none; - background: linear-gradient( - 180deg, - var(--tblr-light) 0%, - var(--tblr-light) 42%, - var(--tblr-secondary-color) 100% - ); - background-clip: text; - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; } .title-website { font-size: 0.985em; - letter-spacing: 0; padding: 4px 0 9px; - background: linear-gradient( - 180deg, - var(--tblr-link-hover-color) 0%, - var(--tblr-primary) 48%, - var(--tblr-muted-color) 100% - ); - background-clip: text; - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - filter: drop-shadow( - 0 2px 2px color-mix(in srgb, var(--tblr-body-bg) 54.90%, transparent) - ); + color: var(--tblr-primary); } .title-from { font-size: 0.985em; @@ -152,14 +83,15 @@ } .intro-row { position: absolute; - left: 4.65%; - top: 72%; + left: 7%; + top: 57%; width: fit-content; max-width: calc(var(--composition-width) * 0.5); z-index: 21; display: flex; - align-items: center; - gap: clamp(28px, 3vw, 60px); + flex-direction: column; + align-items: flex-start; + gap: 28px; opacity: var(--intro-opacity); transform: translateX(var(--intro-shift)); pointer-events: auto; @@ -173,8 +105,6 @@ flex: 1; color: var(--tblr-secondary-color); white-space: nowrap; - text-shadow: 0 2px 4px - color-mix(in srgb, var(--tblr-black) 60.00%, transparent); } .run-link { display: inline-flex; diff --git a/examples/official-site/assets/landing/styles/responsive.css b/examples/official-site/assets/landing/styles/responsive.css index 8ea2eb25..c761490c 100644 --- a/examples/official-site/assets/landing/styles/responsive.css +++ b/examples/official-site/assets/landing/styles/responsive.css @@ -28,13 +28,14 @@ width: 60px; } .engraved-comment { - width: 248px; + width: 18.5vw; + right: 2.5%; } .metal-recess { - padding: 17px 21px 18px; + padding: 12px 14px; } .metal-recess code { - font-size: 13px; + font-size: clamp(10px, 1.2vw, 13px); } .intro-row { gap: 25px; @@ -149,10 +150,6 @@ .metal-recess { padding: 12px 10px; border-radius: 5px; - box-shadow: - inset 0 2px 8px color-mix(in srgb, var(--tblr-black) 73.33%, transparent), - 0 0 0 1px color-mix(in srgb, var(--tblr-blue) 16.86%, transparent), - 0 0 10px color-mix(in srgb, var(--tblr-blue) 6.67%, transparent); } .metal-recess code { grid-template-columns: 1fr; diff --git a/examples/official-site/assets/landing/styles/scene.css b/examples/official-site/assets/landing/styles/scene.css index 04577017..343db7e7 100644 --- a/examples/official-site/assets/landing/styles/scene.css +++ b/examples/official-site/assets/landing/styles/scene.css @@ -1,5 +1,14 @@ /* One sculpture: a fixed opening flight, then native scrolling in section hosts. */ .sqlpage-world { + /* Keep the studio palette local to the landing page. Documentation retains + the shared Tabler theme, and light demo sections retain their own surface. */ + --tblr-body-bg: #080e11; + --tblr-bg-surface: #0d161c; + --tblr-bg-surface-secondary: #14212b; + --tblr-bg-surface-tertiary: #1b2c38; + --tblr-border-color: #263d4d; + --tblr-secondary-color: #a1b4c6; + --tblr-muted-color: #8a9faf; position: relative; overflow-x: clip; background: var(--tblr-body-bg); @@ -26,11 +35,7 @@ overflow: hidden; isolation: isolate; background: - radial-gradient( - ellipse at 79% 26%, - color-mix(in srgb, var(--tblr-border-color) 33.33%, transparent), - transparent 57% - ), + radial-gradient(ellipse at 72% 42%, #203848, transparent 58%), var(--tblr-body-bg); } /* One sculpture follows the authored section anchors. */ @@ -111,22 +116,6 @@ ); opacity: calc(0.9 - var(--progress) * 0.3); } -.surface-finish { - position: absolute; - inset: 0; - z-index: 40; - pointer-events: none; - opacity: 0.2; - background: repeating-linear-gradient( - 0deg, - color-mix(in srgb, var(--tblr-secondary-color) 1.18%, transparent) 0, - color-mix(in srgb, var(--tblr-secondary-color) 1.18%, transparent) 1px, - transparent 1px, - transparent 3px - ); - mix-blend-mode: screen; -} - /* Reserve the opening flight before first paint. The static preview follows the same anchors while WebGL loads or fails, so readiness never shifts the page. */ .sqlpage-world[data-enhanced][data-motion="on"] .experience { diff --git a/examples/official-site/sqlpage/templates/landing-page.handlebars b/examples/official-site/sqlpage/templates/landing-page.handlebars index a65537d4..03a740aa 100644 --- a/examples/official-site/sqlpage/templates/landing-page.handlebars +++ b/examples/official-site/sqlpage/templates/landing-page.handlebars @@ -53,33 +53,32 @@ -
- - -
- Drag in any direction to rotate the 3D model. Scroll the page to - turn the scene. Use arrow keys to rotate, Shift with left - or right to roll, plus and minus to zoom, and Home to reset. On a - touch screen, swipe sideways to start rotating, swipe up or down to - scroll the page, and pinch to zoom the page. -
-
+
+ + +
+ Drag in any direction to rotate the 3D model. Scroll the page to + turn the scene. Use arrow keys to rotate, Shift with left + or right to roll, plus and minus to zoom, and Home to reset. On a + touch screen, swipe sideways to start rotating, swipe up or down to + scroll the page, and pinch to zoom the page. +
+
-