Skip to content

Commit 011ba8a

Browse files
authored
Ground the landing sculpture with studio reflections and stable loading (#1563)
* Refine landing sculpture materials, grounding, and composition * Add visible floor reflections and stable sculpture loading * Unify sculpture metal and soften overexposed highlights * test(landing): preserve GPU fence polling in browser checks * test(landing): simulate slow GPU only after live startup
1 parent 143d9e0 commit 011ba8a

26 files changed

Lines changed: 573 additions & 312 deletions

‎examples/official-site/README.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,19 @@ shared, while failed loads can be retried. The model, lighting map, and static
5656
preview are versioned local assets. The official site's `sqlpage.yaml` allows
5757
jsDelivr scripts while preserving SQLPage's per-request nonce.
5858

59+
The studio is a textured horizontal floor with a 512 × 512 planar reflection,
60+
filtered to 256 × 256 with the same Gaussian shader as the emission halo.
61+
Reflection RGB and coverage are blurred together before tone mapping. Its
62+
contact point follows the actual metal surface as the sculpture rotates.
63+
Materials stay in `sculpture-materials.js`, lighting in `database-scene.js`,
64+
and floor shading in the `studio-*.glsl` snippets. After changing the scene,
65+
run the official site locally, then regenerate both fallback assets with
66+
`node scripts/render-landing-sculpture.mjs http://127.0.0.1:8080` from the
67+
repository root. This uses the production renderer at time zero and all 64
68+
turns; it requires Playwright Chromium and ImageMagick with WebP/AVIF support.
69+
The low-power handoff retains the last live frame while its atlas decodes and
70+
attaches the replacement only after drawing it, without showing the preview again.
71+
5972
Without JavaScript the document retains its content, initial live example, and
6073
navigation. CDN or WebGL failure leaves the static sculpture preview and working
6174
controls, and a retry is available for the optional scene. Reduced motion disables
8.71 KB
Loading
Binary file not shown.

‎examples/official-site/assets/landing/js/scene/database-scene.js‎

Lines changed: 72 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ export function createDatabaseScene({
5454
contextLost = false,
5555
frame = 0,
5656
last = 0,
57-
time = 0;
57+
time = 0,
58+
renderedFrames = 0;
5859
let renderedZoom = 0,
5960
visible = document.visibilityState !== "hidden",
6061
loaded = false,
@@ -91,7 +92,7 @@ export function createDatabaseScene({
9192
renderer.setClearColor(0x000000, 0);
9293
renderer.shadowMap.enabled = true;
9394
renderer.shadowMap.autoUpdate = false;
94-
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
95+
renderer.shadowMap.type = THREE.PCFShadowMap;
9596
let canvas = renderer.domElement;
9697
const canvasStyle =
9798
"position:absolute;left:0;top:0;width:1000px;height:1000px;transform-origin:0 0";
@@ -107,7 +108,8 @@ export function createDatabaseScene({
107108
samples = 0,
108109
slowFrames = 0,
109110
cpuBusyUntil = 0;
110-
let baked, context, lastTurn;
111+
let baked, context, lastTurn, pendingAtlas, previousCanvas;
112+
let switchingRenderer = false;
111113
const resetBudget = () => {
112114
if (fence) gl.deleteSync(fence);
113115
fence = undefined;
@@ -139,33 +141,33 @@ export function createDatabaseScene({
139141
renderer.forceContextLoss();
140142
};
141143
const useBakedViews = () => {
142-
releaseGraphics();
143-
baked = new Image();
144+
if (switchingRenderer) return;
145+
switchingRenderer = true;
146+
const atlas = new Image();
147+
pendingAtlas = atlas;
144148
const replacement = document.createElement("canvas");
145149
replacement.width = 400;
146150
replacement.height = 400;
147-
context = replacement.getContext("2d");
148-
if (!context) {
151+
const replacementContext = replacement.getContext("2d");
152+
if (!replacementContext) {
149153
onError();
150154
return;
151155
}
152-
canvas.replaceWith(replacement);
153-
canvas = replacement;
154-
canvas.style.cssText = canvasStyle;
155-
root.dataset.renderer = "baked";
156-
root.dataset.scene = "loading";
157-
loaded = readySent = false;
158-
hitArea.inert = true;
159-
if (instructions)
160-
instructions.textContent =
161-
"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.";
162-
baked.src = new URL(
156+
atlas.src = new URL(
163157
"../../assets/sculpture-turns.avif",
164158
import.meta.url,
165159
).href;
166-
baked.decode().then(
160+
atlas.decode().then(
167161
() => {
168-
loaded = !disposed;
162+
if (disposed) return;
163+
// Keep the last submitted WebGL frame visible during download/decode.
164+
// The replacement is attached only after its first atlas view is drawn.
165+
baked = atlas;
166+
context = replacementContext;
167+
previousCanvas = canvas;
168+
canvas = replacement;
169+
canvas.style.cssText = canvasStyle;
170+
switchingRenderer = false;
169171
needsRender = true;
170172
},
171173
() => {
@@ -180,25 +182,26 @@ export function createDatabaseScene({
180182
const interior = {
181183
uInteriorPosition: { value: new THREE.Vector3() },
182184
uInteriorRadius: { value: 1 },
183-
uInteriorPulse: { value: 14 },
185+
uInteriorPulse: { value: 2.4 },
184186
};
185-
const interiorLight = new THREE.PointLight(0x72c8de, 0.6, 2.1, 2);
187+
const interiorLight = new THREE.PointLight(0x8bb8e8, 0.4, 2.1, 2);
186188
interiorLight.name = "Soft interior database light";
187189
const interiorScale = new THREE.Vector3();
188190
let glow, jewelReflection;
189-
const key = new THREE.DirectionalLight(0xe5edf1, 1.7);
190-
key.position.set(-3.5, 5, 5);
191+
const key = new THREE.DirectionalLight(0xd8e9fa, 1.2);
192+
key.position.set(-3, 5, 2.5);
191193
key.castShadow = true;
192-
key.shadow.mapSize.set(512, 512);
194+
key.shadow.mapSize.set(1024, 1024);
193195
Object.assign(key.shadow.camera, {
194-
left: -4,
195-
right: 4,
196-
top: 4,
197-
bottom: -4,
196+
left: -3,
197+
right: 3,
198+
top: 3,
199+
bottom: -3,
198200
near: 0.5,
199201
far: 18,
200202
});
201-
key.shadow.normalBias = 0.025;
203+
key.shadow.normalBias = 0.012;
204+
key.shadow.radius = 8;
202205
scene.add(key);
203206
const rim = new THREE.DirectionalLight(0xa1bac7, 0.8);
204207
rim.position.set(4, 2, -2);
@@ -208,12 +211,12 @@ export function createDatabaseScene({
208211
scene.add(fill);
209212

210213
RectAreaLightUniformsLib.init();
211-
const softbox = new THREE.RectAreaLight(0xe5edf1, 7, 2.5, 6);
212-
softbox.position.set(-3.5, 1.5, 4);
214+
const softbox = new THREE.RectAreaLight(0xe5eff9, 9, 1.5, 6);
215+
softbox.position.set(-3, 2, 4.5);
213216
softbox.lookAt(0, 0, 0);
214217
scene.add(softbox);
215-
const edgeSoftbox = new THREE.RectAreaLight(0xa1bac7, 4, 1.2, 5);
216-
edgeSoftbox.position.set(3.5, 1.5, 2);
218+
const edgeSoftbox = new THREE.RectAreaLight(0xa9cbec, 3, 0.7, 4);
219+
edgeSoftbox.position.set(3.5, 2, 3.5);
217220
edgeSoftbox.lookAt(0, 0, 0);
218221
scene.add(edgeSoftbox);
219222
scene.add(new THREE.HemisphereLight(0xa1bac7, 0x18262d, 0.55));
@@ -242,7 +245,7 @@ export function createDatabaseScene({
242245
);
243246
glow.setSize(1000, 1000, 1);
244247
jewelReflection = createFiniteJewelLight(JEWEL_EDGES, shaders);
245-
studioSet = createSculptureStudio();
248+
studioSet = createSculptureStudio(studio, shaders);
246249
scene.add(studioSet.group);
247250
performance.mark("sqlpage:assets-ready");
248251
const bounds = new THREE.Box3().setFromObject(gltf.scene);
@@ -271,17 +274,24 @@ export function createDatabaseScene({
271274
if (material.name.includes("Separator"))
272275
finishLiquid(material, liquidClock, studio, interior, shaders);
273276
if (material.name.includes("Jewel Wire")) {
274-
material.emissiveIntensity = 1.05;
277+
material.color.set("#a9d2fa");
278+
material.emissive.set("#8bb8e8");
279+
material.emissiveIntensity = 1.6;
275280
wireMaterial = material;
276281
}
277-
if (material.name.includes("Endpoint"))
278-
material.emissiveIntensity = 1.2;
282+
if (material.name.includes("Endpoint")) {
283+
material.color.set("#c9e2fb");
284+
material.emissive.set("#a9d2fa");
285+
material.emissiveIntensity = 1.8;
286+
}
287+
if (material.name.includes("Facets")) {
288+
material.color.set("#8bb8e8");
289+
material.emissive.set("#466689");
290+
material.opacity = 0.035;
291+
}
279292
if (material.transparent) material.depthWrite = false;
280293
}
281294
});
282-
const jewelLight = new THREE.PointLight(0x72c8de, 0.09, 2.4, 2);
283-
jewelLight.position.set(0, 1.35, 0);
284-
gltf.scene.add(jewelLight);
285295
// Native model coordinates: stay inside the radius-1.04 database stack.
286296
interiorLight.position.set(0.4, -0.45, 0);
287297
gltf.scene.add(interiorLight);
@@ -428,6 +438,7 @@ export function createDatabaseScene({
428438
}
429439
if (
430440
loaded &&
441+
!switchingRenderer &&
431442
(inView || !readySent) &&
432443
(state.motion || needsRender || changing)
433444
) {
@@ -461,6 +472,15 @@ export function createDatabaseScene({
461472
}
462473
lastTurn = turn;
463474
}
475+
if (previousCanvas) {
476+
previousCanvas.replaceWith(canvas);
477+
previousCanvas = undefined;
478+
releaseGraphics();
479+
root.dataset.renderer = "baked";
480+
if (instructions)
481+
instructions.textContent =
482+
"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.";
483+
}
464484
} else {
465485
// Prepare GPU-only animation only when a new live frame can be drawn.
466486
liquidClock.value = time;
@@ -473,9 +493,9 @@ export function createDatabaseScene({
473493
-0.45 + Math.sin(time * ((Math.PI * 2) / 4)) * 0.28,
474494
Math.sin(time * ((Math.PI * 2) / 6)) * 0.4,
475495
);
476-
const pulse = 14 + Math.sin(time * ((Math.PI * 2) / 3)) * 6;
496+
const pulse = 2.4 + Math.sin(time * ((Math.PI * 2) / 6)) * 0.3;
477497
interior.uInteriorPulse.value = pulse;
478-
interiorLight.intensity = (0.6 * pulse) / 14;
498+
interiorLight.intensity = (0.4 * pulse) / 2.4;
479499
scene.updateMatrixWorld(true);
480500
studioSet.update(model);
481501
scene.updateMatrixWorld(true);
@@ -485,15 +505,19 @@ export function createDatabaseScene({
485505
if (diamond && wireMaterial)
486506
jewelReflection.update(diamond, camera, wireMaterial);
487507
syncHitArea();
508+
studioSet.renderReflection(renderer, scene, camera);
488509
renderer.shadowMap.needsUpdate = true;
489510
renderer.render(scene, camera);
490511
glow.render(model, [studioSet.group]);
491512
fence = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0);
492513
submittedAt = performance.now();
514+
renderedFrames++;
493515
}
494-
needsRender = false;
516+
// The reflection receiver settles on the second submitted frame. Keep
517+
// the matching preview visible and time frozen throughout initialization.
518+
needsRender = !baked && renderedFrames < 2;
495519
lastOrientation.copy(dragQuaternion);
496-
if (!readySent) {
520+
if (!readySent && (baked || renderedFrames >= 2)) {
497521
readySent = true;
498522
performance.mark("sqlpage:first-frame");
499523
performance.measure(
@@ -530,7 +554,9 @@ export function createDatabaseScene({
530554
timing?.disconnect();
531555
if (instructions) instructions.textContent = originalInstructions;
532556
if (baked) baked.src = "";
533-
if (!baked) releaseGraphics();
557+
if (pendingAtlas) pendingAtlas.src = "";
558+
if (!baked || previousCanvas) releaseGraphics();
559+
previousCanvas?.remove();
534560
canvas.remove();
535561
},
536562
};

‎examples/official-site/assets/landing/js/scene/landing-frame.js‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ function frameAt(width, height, anchor, portrait, bodyWidth = width * 0.48) {
2020
const compositionLeft = (width - compositionWidth) / 2;
2121
const desiredWidth = portrait
2222
? bodyWidth
23-
: Math.min(compositionWidth * 0.335, height * 0.59);
23+
: Math.min(compositionWidth * 0.26, height * 0.47);
2424
let size = desiredWidth / SCULPTURE_FRAME.bodyWidth;
2525
const align = () => {
2626
const left = anchor.x - SCULPTURE_FRAME.shoulder.x * size;
@@ -60,6 +60,7 @@ function frameAt(width, height, anchor, portrait, bodyWidth = width * 0.48) {
6060
export function layoutLandingFrame(viewport, anchor) {
6161
const width = Math.max(1, viewport.clientWidth);
6262
const screenHeight = window.innerHeight;
63+
const compositionWidth = Math.min(width, screenHeight * 1.95);
6364
const portrait = width < 900 && screenHeight >= width;
6465
const shortLandscape = !portrait && screenHeight < 560;
6566
const section = viewport.parentElement;
@@ -108,13 +109,16 @@ export function layoutLandingFrame(viewport, anchor) {
108109
rect.left -
109110
surface.left +
110111
(ink.actualBoundingBoxRight || rect.width) -
111-
fontSize * 0.045,
112+
fontSize * 0.045 +
113+
// Leave a clear gutter between the compact headline and sculpture.
114+
compositionWidth * 0.2,
112115
y:
113116
rect.bottom -
114117
surface.top -
115118
(ink.fontBoundingBoxDescent ?? fontSize * 0.2) +
116119
ink.actualBoundingBoxDescent -
117-
fontSize * 0.025,
120+
fontSize * 0.025 -
121+
height * 0.025,
118122
};
119123
};
120124
if (portrait)

‎examples/official-site/assets/landing/js/scene/sculpture-materials.js‎

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,13 @@ function localPosition(shader, varying) {
3131

3232
export function finishMetal(material, environment, shaders) {
3333
const lid = material.name.includes("Mirror Top");
34-
const rim = material.name.includes("Gunmetal");
35-
material.color.set(lid ? "#384651" : rim ? "#adb8c2" : "#9ca8b2");
36-
material.metalness = lid ? 0.96 : 0.9;
37-
// Keep the cap polished: the satin shell roughness blurs the reflected wires.
38-
material.roughness = lid ? 0.09 : rim ? 0.24 : 0.3;
34+
// One dark satin metal across the lid, shell and rims. Surface orientation
35+
// and machining direction shape the highlights, rather than different alloys.
36+
material.color.set("#4a5a68");
37+
material.metalness = 1;
38+
material.roughness = 0.34;
3939
material.envMap = environment;
40-
material.envMapIntensity =
41-
(lid ? 0.82 : rim ? 1.15 : 1.08) * STUDIO_RADIANCE_SCALE;
40+
material.envMapIntensity = 0.6 * STUDIO_RADIANCE_SCALE;
4241
material.defines = { ...material.defines, METAL_LID: lid ? 1 : 0 };
4342
material.onBeforeCompile = (shader) => {
4443
localPosition(shader, "vMetalPosition");
@@ -48,16 +47,16 @@ export function finishMetal(material, environment, shaders) {
4847
);
4948
};
5049
material.customProgramCacheKey = () =>
51-
`sqlpage-black-metal-${lid ? "lid" : "shell"}-v5`;
50+
`sqlpage-satin-metal-${lid ? "lid" : "shell"}-v7`;
5251
}
5352

5453
export function finishLiquid(material, clock, environment, interior, shaders) {
5554
const rim = material.name.includes("Rims");
56-
material.color.set(rim ? "#133340" : "#06131c");
57-
material.emissive.set(rim ? "#246582" : "#14638a");
58-
material.emissiveIntensity = rim ? 0.28 : 0.55;
59-
material.metalness = 0.2;
60-
material.roughness = rim ? 0.32 : 0.25;
55+
material.color.set(rim ? "#527ba5" : "#345576");
56+
material.emissive.set("#719fe8");
57+
material.emissiveIntensity = rim ? 0.45 : 0.65;
58+
material.metalness = 0.25;
59+
material.roughness = rim ? 0.23 : 0.18;
6160
material.envMap = environment;
6261
material.envMapIntensity = 0.18 * STUDIO_RADIANCE_SCALE;
6362
material.transparent = false;

0 commit comments

Comments
 (0)