Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions examples/official-site/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
118 changes: 72 additions & 46 deletions examples/official-site/assets/landing/js/scene/database-scene.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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;
},
() => {
Expand All @@ -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);
Expand All @@ -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));
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -428,6 +438,7 @@ export function createDatabaseScene({
}
if (
loaded &&
!switchingRenderer &&
(inView || !readySent) &&
(state.motion || needsRender || changing)
) {
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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(
Expand Down Expand Up @@ -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();
},
};
Expand Down
10 changes: 7 additions & 3 deletions examples/official-site/assets/landing/js/scene/landing-frame.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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;
Expand Down
Loading
Loading