Files
ai.interactive.fiction/public/js/webgl-book-lab.js
T

2868 lines
123 KiB
JavaScript

import * as THREE from 'https://esm.sh/three@0.165.0';
import { EffectComposer } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/RenderPass.js';
import { SSAOPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/SSAOPass.js';
import { SMAAPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/SMAAPass.js';
import { OutputPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/OutputPass.js';
import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260606-book-page-format-restore';
const canvas = document.getElementById('scene');
canvas.style.cursor = 'grab';
const tableDebugModes = {
none: 0,
shadow: 1,
dust: 2,
normal: 3,
room: 4,
scene: 5,
mask: 6,
ao: 7,
grease: 8,
mirror: 10
};
const urlParams = new URLSearchParams(window.location.search);
const appInitialState = window.WebGLBookInitialState || {};
const tableDebugName = urlParams.get('tableDebug') || 'none';
const tableDebugMode = tableDebugModes[tableDebugName] ?? tableDebugModes.none;
const isAppIntegrationMode = appInitialState.appMode === true;
const labStatus = document.getElementById('lab_status');
if (labStatus && tableDebugMode !== tableDebugModes.none) {
labStatus.textContent = tableDebugName === 'ao' ? 'scene debug: SSAO' : `table debug: ${tableDebugName}`;
}
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.12;
renderer.shadowMap.enabled = false;
renderer.shadowMap.type = THREE.VSMShadowMap;
const generatedTextureCanvases = {};
const maxTextureAnisotropy = renderer.capabilities.getMaxAnisotropy();
const reflectionPixelRatio = Math.min(window.devicePixelRatio || 1, 2);
const pageTextureWidth = isAppIntegrationMode ? 1280 : 3200;
const reflectionTargetSize = new THREE.Vector2();
const pageRaycaster = new THREE.Raycaster();
const pointerNdc = new THREE.Vector2();
let sceneComposerTarget = null;
let composer = null;
let sceneRenderPass = null;
let sceneAoPass = null;
let sceneSmaaPass = null;
let sceneOutputPass = null;
const aoExcludedObjects = new Set();
let renderedFrameCount = 0;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x080604);
scene.fog = new THREE.FogExp2(0x080604, 0.035);
let candleBounceLight = null;
let tableMesh = null;
let tableShader = null;
let tableRoomReflectionTexture = createRoomReflectionTexture();
let tableDustTexture = null;
let tableGreaseTexture = null;
const tableTopY = -0.02;
const bookTableContactClearance = 0.002;
const tableReflectionBaseWidth = isAppIntegrationMode ? 1280 : 4096;
const tableReflectionBaseHeight = isAppIntegrationMode ? 720 : 2304;
const tableReflectionTarget = new THREE.WebGLRenderTarget(tableReflectionBaseWidth, tableReflectionBaseHeight, {
colorSpace: THREE.SRGBColorSpace,
depthBuffer: true,
stencilBuffer: false,
samples: renderer.capabilities.isWebGL2 ? (isAppIntegrationMode ? 2 : 8) : 0
});
tableReflectionTarget.texture.colorSpace = THREE.SRGBColorSpace;
tableReflectionTarget.texture.minFilter = THREE.LinearFilter;
tableReflectionTarget.texture.magFilter = THREE.LinearFilter;
tableReflectionTarget.texture.anisotropy = maxTextureAnisotropy;
const tableReflectionCamera = new THREE.PerspectiveCamera();
const tableReflectionMatrix = new THREE.Matrix4();
const tableReflectionBiasMatrix = new THREE.Matrix4().set(
0.5, 0, 0, 0.5,
0, 0.5, 0, 0.5,
0, 0, 0.5, 0.5,
0, 0, 0, 1
);
const reflectionTarget = new THREE.Vector3();
const reflectionUp = new THREE.Vector3();
const candleShadowSources = [];
const candleWorldPosition = new THREE.Vector3();
const flameWorldPosition = new THREE.Vector3();
const bookShadowMapSize = isAppIntegrationMode ? 512 : 1536;
const bookShadowTargets = Array.from({ length: 3 }, () => {
const target = new THREE.WebGLRenderTarget(bookShadowMapSize, bookShadowMapSize, {
colorSpace: THREE.NoColorSpace,
depthBuffer: true,
stencilBuffer: false
});
target.texture.colorSpace = THREE.NoColorSpace;
target.texture.minFilter = THREE.LinearFilter;
target.texture.magFilter = THREE.LinearFilter;
target.texture.generateMipmaps = false;
return target;
});
const bookShadowCameras = Array.from({ length: 3 }, () => new THREE.PerspectiveCamera(78, 1, 0.03, 7.2));
const bookShadowMatrices = Array.from({ length: 3 }, () => new THREE.Matrix4());
const bookShadowBiasMatrix = new THREE.Matrix4().set(
0.5, 0, 0, 0.5,
0, 0.5, 0, 0.5,
0, 0, 0.5, 0.5,
0, 0, 0, 1
);
const bookShadowDepthMaterial = new THREE.MeshDepthMaterial({
depthPacking: THREE.RGBADepthPacking
});
bookShadowDepthMaterial.blending = THREE.NoBlending;
const camera = new THREE.PerspectiveCamera(28, 1, 0.1, 40);
const cameraRig = {
target: new THREE.Vector3(0, 0.16, -0.04),
yaw: 0,
pitch: 1.06,
radius: 5.54,
minPitch: 0.28,
maxPitch: 1.34,
minRadius: 2.4,
maxRadius: 9.0,
dragging: false,
navigationActive: false,
pointerX: 0,
pointerY: 0,
keys: new Set()
};
if (urlParams.get('view') === 'wide') {
cameraRig.target.set(0, 0.05, 0);
cameraRig.pitch = 0.96;
cameraRig.radius = 7.8;
}
updateCameraRig(0);
configureScenePostprocessing();
const clock = new THREE.Clock();
const book = new THREE.Group();
scene.add(book);
const initialReadingProgress = THREE.MathUtils.clamp(Number.parseFloat(urlParams.get('progress') ?? appInitialState.progress ?? '0.28'), 0, 1);
let readingProgress = Number.isFinite(initialReadingProgress) ? initialReadingProgress : 0.28;
let bookPageCount = snapProceduralPageCount(urlParams.get('pages') ?? appInitialState.pageCount ?? '240');
let currentProceduralBookModel = null;
const progressInput = document.getElementById('progress_control');
const progressValue = document.getElementById('progress_value');
const pageCountInput = document.getElementById('page_count_control');
const pageCountValue = document.getElementById('page_count_value');
const fastBackwardButton = document.getElementById('fast_backward');
const backwardButton = document.getElementById('flip_backward');
const forwardButton = document.getElementById('flip_forward');
const fastForwardButton = document.getElementById('fast_forward');
const normalFlipDuration = 900;
const fastFlipDuration = 520;
const fastFlipCount = 10;
const fastFlipOverlap = 5;
let activeFlips = [];
let pendingPageFlips = 0;
const paperColor = new THREE.Color(0xf3dfad);
const inkColor = '#1a1009';
const leftCanvas = createPageCanvas('left');
const rightCanvas = createPageCanvas('right');
const leftTexture = new THREE.CanvasTexture(leftCanvas);
const rightTexture = new THREE.CanvasTexture(rightCanvas);
[leftTexture, rightTexture].forEach((texture) => {
texture.colorSpace = THREE.SRGBColorSpace;
texture.anisotropy = maxTextureAnisotropy;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = true;
});
const leatherTextures = createLeatherTextures();
const spineClothTextures = createSpineClothTextures();
const headbandTextures = createHeadbandTextures();
const paperTextures = createHardcoverPaperTextures();
const materials = {
leather: new THREE.MeshStandardMaterial({
color: 0x25130b,
map: leatherTextures.color,
normalMap: leatherTextures.normal,
normalScale: new THREE.Vector2(0.07, 0.07),
roughnessMap: leatherTextures.roughness,
roughness: 0.78,
metalness: 0.02,
envMapIntensity: 0.1,
side: THREE.DoubleSide
}),
hingeLeather: new THREE.MeshStandardMaterial({
color: 0x32180c,
map: leatherTextures.color,
normalMap: leatherTextures.normal,
normalScale: new THREE.Vector2(0.062, 0.062),
roughnessMap: leatherTextures.roughness,
roughness: 0.82,
metalness: 0.015,
envMapIntensity: 0.08,
side: THREE.DoubleSide
}),
spineBaseLeather: new THREE.MeshStandardMaterial({
color: 0x2a1209,
map: leatherTextures.color,
normalMap: leatherTextures.normal,
normalScale: new THREE.Vector2(0.055, 0.055),
roughnessMap: leatherTextures.roughness,
roughness: 0.86,
metalness: 0.01,
envMapIntensity: 0.06,
side: THREE.DoubleSide
}),
coverEdge: new THREE.MeshStandardMaterial({
color: 0x5b351b,
map: leatherTextures.color,
normalMap: leatherTextures.normal,
normalScale: new THREE.Vector2(0.068, 0.068),
roughnessMap: leatherTextures.roughness,
roughness: 0.8,
metalness: 0.015,
envMapIntensity: 0.07,
side: THREE.DoubleSide
}),
pageBlock: new THREE.MeshStandardMaterial({
color: 0xfffbef,
map: paperTextures.color,
normalMap: paperTextures.normal,
normalScale: new THREE.Vector2(0.032, 0.032),
roughnessMap: paperTextures.roughness,
roughness: 0.88,
metalness: 0,
envMapIntensity: 0.06
}),
pageEdge: new THREE.MeshStandardMaterial({
color: 0xfff4cf,
map: paperTextures.edge,
normalMap: paperTextures.normal,
normalScale: new THREE.Vector2(0.024, 0.024),
roughnessMap: paperTextures.roughness,
roughness: 0.94,
metalness: 0,
envMapIntensity: 0.05
}),
pageSurface: new THREE.MeshStandardMaterial({
color: 0xfffbf0,
map: paperTextures.color,
normalMap: paperTextures.normal,
normalScale: new THREE.Vector2(0.03, 0.03),
roughnessMap: paperTextures.roughness,
roughness: 0.9,
metalness: 0,
emissive: 0x14110b,
emissiveIntensity: 0.025,
envMapIntensity: 0.035,
side: THREE.DoubleSide
}),
flipPageSurface: new THREE.MeshStandardMaterial({
color: 0xfffcf2,
roughness: 0.92,
metalness: 0,
emissive: 0x100d08,
emissiveIntensity: 0.018,
envMapIntensity: 0.02,
side: THREE.DoubleSide
}),
leftPage: new THREE.MeshStandardMaterial({
color: 0xffffff,
map: leftTexture,
normalMap: paperTextures.normal,
normalScale: new THREE.Vector2(0.025, 0.025),
roughnessMap: paperTextures.roughness,
roughness: 0.86,
metalness: 0,
emissive: 0x11100c,
emissiveIntensity: 0.035,
side: THREE.DoubleSide
}),
rightPage: new THREE.MeshStandardMaterial({
color: 0xffffff,
map: rightTexture,
normalMap: paperTextures.normal,
normalScale: new THREE.Vector2(0.025, 0.025),
roughnessMap: paperTextures.roughness,
roughness: 0.86,
metalness: 0,
emissive: 0x11100c,
emissiveIntensity: 0.035,
side: THREE.DoubleSide
}),
spineCloth: new THREE.MeshStandardMaterial({
color: 0xa51d1d,
map: spineClothTextures.color,
normalMap: spineClothTextures.normal,
normalScale: new THREE.Vector2(0.07, 0.07),
roughnessMap: spineClothTextures.roughness,
roughness: 0.86,
metalness: 0,
envMapIntensity: 0.075,
side: THREE.DoubleSide
}),
headband: new THREE.MeshStandardMaterial({
color: 0xffffff,
map: headbandTextures.color,
normalMap: headbandTextures.normal,
normalScale: new THREE.Vector2(0.055, 0.055),
roughnessMap: headbandTextures.roughness,
roughness: 0.96,
metalness: 0,
envMapIntensity: 0
})
};
materials.spineCloth.userData.isSpineCloth = true;
materials.headband.userData.isHeadband = true;
configureHardcoverPaperMaterial(materials.pageBlock);
configureHardcoverPaperMaterial(materials.pageEdge, { useEdgeMap: true });
configureHardcoverPaperMaterial(materials.pageSurface);
configureHardcoverPaperMaterial(materials.leftPage);
configureHardcoverPaperMaterial(materials.rightPage);
configureBookShadowReceiver(materials.leather, 0.52);
configureBookShadowReceiver(materials.hingeLeather, 0.36);
configureBookShadowReceiver(materials.spineBaseLeather, 0.34);
configureBookShadowReceiver(materials.coverEdge, 0.28);
configureBookShadowReceiver(materials.pageBlock, 0.46);
configureBookShadowReceiver(materials.pageEdge, 0.34);
configureBookShadowReceiver(materials.pageSurface, 0.34);
configureBookShadowReceiver(materials.flipPageSurface, 0.32);
configureBookShadowReceiver(materials.leftPage, 0.38);
configureBookShadowReceiver(materials.rightPage, 0.38);
configureBookShadowReceiver(materials.spineCloth, 0.48);
configureBookShadowReceiver(materials.headband, 0.62);
buildTable();
buildLighting();
buildBook();
loadAiRoomReflection();
window.BookLabDebug = {
textures: generatedTextureCanvases,
ready: false,
renderedFrames: 0,
get sceneAoPass() {
return sceneAoPass;
},
get composer() {
return composer;
},
get readingProgress() {
return readingProgress;
},
get bookModel() {
return currentProceduralBookModel;
},
get bookConstants() {
return PROCEDURAL_BOOK;
},
get bookPlacement() {
return {
tableTopY,
bookGroupY: book.position.y,
contactClearance: book.position.y - tableTopY
};
},
get bookParts() {
const parts = [];
book.traverse((object) => {
if (!object.isMesh) return;
parts.push({
part: object.userData.bookPart ?? 'unknown',
castShadow: object.castShadow,
receiveShadow: object.receiveShadow,
materialType: Array.isArray(object.material)
? object.material.map((material) => material.type).join(',')
: object.material?.type
});
});
return parts;
},
get activeFlips() {
return activeFlips.length;
},
get pendingPageFlips() {
return pendingPageFlips;
},
setReadingProgress(value) {
setReadingProgress(value);
return readingProgress;
},
setBookPageCount(value) {
setBookPageCount(value);
return bookPageCount;
},
redrawPageTextures() {
window.BookTextureRenderer?.publishSpread?.();
return true;
},
getTextureInfo() {
return {
pageTextureWidth,
pageTextureHeight: leftCanvas.height,
debug: getPageTextureDebugState()
};
},
projectPointerToPage(clientX, clientY) {
return projectPointerToPage(clientX, clientY);
},
exportTexture(name) {
if (name === 'left' || name === 'leftPage') return leftCanvas.toDataURL('image/png');
if (name === 'right' || name === 'rightPage') return rightCanvas.toDataURL('image/png');
return generatedTextureCanvases[name]?.toDataURL('image/png') || null;
}
};
window.addEventListener('resize', resize);
document.addEventListener('webgl-book:page-canvases', handlePageCanvases);
installBookControls();
installCameraControls();
resize();
document.dispatchEvent(new CustomEvent('webgl-book:scene-ready'));
animate();
function buildTable() {
const tableTexture = new THREE.TextureLoader().load('/assets/webgl/wood_table_diff_1k.jpg');
tableTexture.colorSpace = THREE.SRGBColorSpace;
tableTexture.wrapS = THREE.RepeatWrapping;
tableTexture.wrapT = THREE.RepeatWrapping;
tableTexture.repeat.set(2.15, 1.45);
tableTexture.anisotropy = renderer.capabilities.getMaxAnisotropy();
const tableNormal = loadUtilityTexture('/assets/webgl/table_normal_2k.png');
tableNormal.wrapS = THREE.RepeatWrapping;
tableNormal.wrapT = THREE.RepeatWrapping;
tableNormal.repeat.set(2.15, 1.45);
tableNormal.anisotropy = renderer.capabilities.getMaxAnisotropy();
tableDustTexture = loadUtilityTexture('/assets/webgl/table_dust_4k.png');
tableDustTexture.wrapS = THREE.ClampToEdgeWrapping;
tableDustTexture.wrapT = THREE.ClampToEdgeWrapping;
tableDustTexture.anisotropy = renderer.capabilities.getMaxAnisotropy();
tableGreaseTexture = loadUtilityTexture('/assets/webgl/table_grease_4k.png');
tableGreaseTexture.wrapS = THREE.ClampToEdgeWrapping;
tableGreaseTexture.wrapT = THREE.ClampToEdgeWrapping;
tableGreaseTexture.anisotropy = renderer.capabilities.getMaxAnisotropy();
const tableMaterial = new THREE.MeshPhysicalMaterial({
color: 0x8a4c22,
map: tableTexture,
normalMap: tableNormal,
normalScale: new THREE.Vector2(0.22, 0.18),
roughness: 0.42,
metalness: 0,
clearcoat: 0.32,
clearcoatRoughness: 0.58,
reflectivity: 0.18,
envMapIntensity: 0
});
configureTableShader(tableMaterial);
tableMesh = new THREE.Mesh(new THREE.BoxGeometry(9.8, 0.28, 6.6, 1, 1, 1), tableMaterial);
tableMesh.position.y = -0.16;
tableMesh.receiveShadow = false;
scene.add(tableMesh);
}
function loadUtilityTexture(url) {
const texture = new THREE.TextureLoader().load(url);
texture.colorSpace = THREE.NoColorSpace;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = true;
return texture;
}
function configureBookShadowReceiver(material, strength) {
const isSpineCloth = material.userData?.isSpineCloth === true;
const isHardcoverPaper = material.userData?.isHardcoverPaper === true;
const isHeadband = material.userData?.isHeadband === true;
material.customProgramCacheKey = () => `book-shadow-receiver-${strength.toFixed(2)}-${isHeadband ? 'headband-v1' : isSpineCloth ? 'spine-cloth-v4' : isHardcoverPaper ? 'hardcover-paper-v1' : 'plain'}`;
material.onBeforeCompile = (shader) => {
shader.uniforms.bookShadowMaps = { value: bookShadowTargets.map((target) => target.texture) };
shader.uniforms.bookShadowMatrices = { value: bookShadowMatrices };
shader.uniforms.bookShadowMapTexelSize = { value: new THREE.Vector2(1 / bookShadowMapSize, 1 / bookShadowMapSize) };
shader.uniforms.bookShadowReceiverStrength = { value: strength };
shader.uniforms.bookTableTopY = { value: tableTopY };
shader.vertexShader = shader.vertexShader
.replace(
'#include <common>',
`#include <common>
varying vec3 vBookReceiverWorldPosition;
varying vec3 vBookReceiverWorldNormal;
${isSpineCloth || isHardcoverPaper || isHeadband ? 'varying vec2 vBookSurfaceUv;' : ''}`
)
.replace(
'#include <defaultnormal_vertex>',
`#include <defaultnormal_vertex>
vBookReceiverWorldNormal = normalize(mat3(modelMatrix) * objectNormal);`
)
.replace(
'#include <begin_vertex>',
`${isSpineCloth || isHardcoverPaper || isHeadband ? 'vBookSurfaceUv = uv;' : ''}
#include <begin_vertex>`
)
.replace(
'#include <project_vertex>',
'vBookReceiverWorldPosition = (modelMatrix * vec4(transformed, 1.0)).xyz;\n#include <project_vertex>'
);
shader.fragmentShader = shader.fragmentShader
.replace(
'#include <common>',
`#include <common>
uniform sampler2D bookShadowMaps[3];
uniform mat4 bookShadowMatrices[3];
uniform vec2 bookShadowMapTexelSize;
uniform float bookShadowReceiverStrength;
uniform float bookTableTopY;
varying vec3 vBookReceiverWorldPosition;
varying vec3 vBookReceiverWorldNormal;
${isSpineCloth || isHardcoverPaper || isHeadband ? 'varying vec2 vBookSurfaceUv;' : ''}
float bookReceiverUnpackRGBADepth(vec4 packedDepth) {
const vec4 unpackFactors = vec4(
1.0 / (256.0 * 256.0 * 256.0),
1.0 / (256.0 * 256.0),
1.0 / 256.0,
1.0
);
return dot(packedDepth, unpackFactors);
}
float bookReceiverCompare(vec4 packedDepth, float currentDepth) {
float closestDepth = bookReceiverUnpackRGBADepth(packedDepth);
return smoothstep(0.003, 0.022, currentDepth - closestDepth - 0.0045);
}
float bookReceiverSample0(vec4 shadowCoord) {
vec3 coord = shadowCoord.xyz / max(shadowCoord.w, 0.0001);
float inBounds = step(0.0, coord.x) * step(0.0, coord.y) *
step(coord.x, 1.0) * step(coord.y, 1.0) * step(0.0, coord.z) * step(coord.z, 1.0);
if (inBounds < 0.5) return 0.0;
float shadow = 0.0;
for (int x = -1; x <= 1; x++) {
for (int y = -1; y <= 1; y++) {
vec2 offset = vec2(float(x), float(y)) * bookShadowMapTexelSize * 1.35;
shadow += bookReceiverCompare(texture2D(bookShadowMaps[0], coord.xy + offset), coord.z);
}
}
return clamp(shadow / 9.0, 0.0, 1.0) * inBounds;
}
float bookReceiverSample1(vec4 shadowCoord) {
vec3 coord = shadowCoord.xyz / max(shadowCoord.w, 0.0001);
float inBounds = step(0.0, coord.x) * step(0.0, coord.y) *
step(coord.x, 1.0) * step(coord.y, 1.0) * step(0.0, coord.z) * step(coord.z, 1.0);
if (inBounds < 0.5) return 0.0;
float shadow = 0.0;
for (int x = -1; x <= 1; x++) {
for (int y = -1; y <= 1; y++) {
vec2 offset = vec2(float(x), float(y)) * bookShadowMapTexelSize * 1.35;
shadow += bookReceiverCompare(texture2D(bookShadowMaps[1], coord.xy + offset), coord.z);
}
}
return clamp(shadow / 9.0, 0.0, 1.0) * inBounds;
}
float bookReceiverSample2(vec4 shadowCoord) {
vec3 coord = shadowCoord.xyz / max(shadowCoord.w, 0.0001);
float inBounds = step(0.0, coord.x) * step(0.0, coord.y) *
step(coord.x, 1.0) * step(coord.y, 1.0) * step(0.0, coord.z) * step(coord.z, 1.0);
if (inBounds < 0.5) return 0.0;
float shadow = 0.0;
for (int x = -1; x <= 1; x++) {
for (int y = -1; y <= 1; y++) {
vec2 offset = vec2(float(x), float(y)) * bookShadowMapTexelSize * 1.35;
shadow += bookReceiverCompare(texture2D(bookShadowMaps[2], coord.xy + offset), coord.z);
}
}
return clamp(shadow / 9.0, 0.0, 1.0) * inBounds;
}
float bookReceiverShadowField(vec3 worldPosition) {
float shadow0 = bookReceiverSample0(bookShadowMatrices[0] * vec4(worldPosition, 1.0));
float shadow1 = bookReceiverSample1(bookShadowMatrices[1] * vec4(worldPosition, 1.0));
float shadow2 = bookReceiverSample2(bookShadowMatrices[2] * vec4(worldPosition, 1.0));
return clamp(max(max(shadow0, shadow1), shadow2), 0.0, 1.0);
}
vec3 bookLocalBounce(vec3 worldPosition, vec3 worldNormal, float shadow, vec3 albedo) {
float tableDistance = max(0.0, worldPosition.y - bookTableTopY);
float tableReach = 1.0 - smoothstep(0.0, 0.34, tableDistance);
float sideReach = 1.0 - smoothstep(0.02, 0.62, tableDistance);
float grazingSide = 1.0 - pow(abs(worldNormal.y), 0.65);
float upFacing = smoothstep(0.24, 0.88, worldNormal.y);
float underside = smoothstep(0.16, 0.88, -worldNormal.y);
float sideFill = grazingSide * sideReach;
float tableFill = tableReach * (0.16 + underside * 0.22) * (1.0 - upFacing * 0.58);
float pageFill = smoothstep(0.02, 0.2, tableDistance) * (1.0 - smoothstep(0.24, 0.72, tableDistance));
vec3 tableWarmth = vec3(0.058, 0.039, 0.026) * tableFill;
vec3 roomWarmth = vec3(0.04, 0.034, 0.028) * sideFill;
vec3 pageWarmth = vec3(0.045, 0.041, 0.034) * pageFill * grazingSide * (1.0 - upFacing * 0.42);
vec3 indirect = tableWarmth + roomWarmth + pageWarmth;
return albedo * indirect * mix(1.0, 0.72, shadow);
}
float spineClothThread(float coordinate, float frequency, float sharpness) {
float wave = abs(fract(coordinate * frequency) - 0.5) * 2.0;
return pow(1.0 - wave, sharpness);
}
vec3 spineClothLight(vec2 uv, vec3 baseLight) {
float warp = spineClothThread(uv.x + sin(uv.y * 18.0) * 0.002, 92.0, 2.4);
float weft = spineClothThread(uv.y + sin(uv.x * 21.0) * 0.0016, 64.0, 2.1);
float fineFiber = sin((uv.x * 420.0 + uv.y * 55.0) * 6.28318530718) *
sin((uv.y * 380.0 - uv.x * 33.0) * 6.28318530718);
float raisedThread = clamp(warp * 0.58 + weft * 0.44, 0.0, 1.0);
float valley = clamp((1.0 - warp) * (1.0 - weft), 0.0, 1.0);
vec3 threadTint = mix(vec3(0.72, 0.24, 0.2), vec3(1.34, 0.86, 0.68), raisedThread);
float fiberShade = 0.96 + fineFiber * 0.03 - valley * 0.11;
return baseLight * threadTint * fiberShade;
}
float paperFiber(float coordinate, float frequency, float sharpness) {
float wave = abs(fract(coordinate * frequency) - 0.5) * 2.0;
return pow(1.0 - wave, sharpness);
}
vec3 hardcoverPaperLight(vec2 uv, vec3 baseLight) {
float fleck = sin((uv.x * 241.0 + uv.y * 97.0) * 6.28318530718) *
sin((uv.y * 211.0 - uv.x * 53.0) * 6.28318530718);
float cloud = sin((uv.x * 17.0 + uv.y * 11.0) * 6.28318530718) *
sin((uv.x * 29.0 - uv.y * 23.0) * 6.28318530718);
float fiber = clamp(fleck * 0.018 + cloud * 0.022, -0.04, 0.05);
vec3 paperTint = mix(vec3(0.96, 0.945, 0.89), vec3(1.08, 1.055, 0.98), clamp(0.62 + fiber, 0.0, 1.0));
return baseLight * paperTint;
}
vec3 headbandCreviceLight(vec2 uv, vec3 baseLight) {
float wrapRidge = spineClothThread(uv.x * 0.72 + uv.y * 4.8, 58.0, 0.7);
float fiber = spineClothThread(uv.y + uv.x * 0.08, 72.0, 1.35);
float relief = 0.82 + wrapRidge * 0.1 + fiber * 0.04;
return baseLight * relief;
}`
)
.replace(
'#include <opaque_fragment>',
`${isSpineCloth ? 'outgoingLight = spineClothLight(vBookSurfaceUv, outgoingLight);' : ''}
${isHardcoverPaper ? 'outgoingLight = hardcoverPaperLight(vBookSurfaceUv, outgoingLight);' : ''}
${isHeadband ? 'outgoingLight = headbandCreviceLight(vBookSurfaceUv, outgoingLight);' : ''}
float bookReceiverShadow = bookReceiverShadowField(vBookReceiverWorldPosition) * bookShadowReceiverStrength;
outgoingLight *= mix(vec3(1.0), ${isHeadband ? 'vec3(0.16, 0.095, 0.055)' : 'vec3(0.38, 0.29, 0.2)'}, bookReceiverShadow);
outgoingLight += bookLocalBounce(vBookReceiverWorldPosition, normalize(vBookReceiverWorldNormal), bookReceiverShadow, diffuseColor.rgb);
#include <opaque_fragment>`
);
};
}
function configureScenePostprocessing() {
sceneComposerTarget = new THREE.WebGLRenderTarget(1, 1, {
colorSpace: THREE.SRGBColorSpace,
depthBuffer: true,
stencilBuffer: false,
samples: renderer.capabilities.isWebGL2 ? 8 : 0
});
sceneComposerTarget.texture.colorSpace = THREE.SRGBColorSpace;
sceneComposerTarget.texture.minFilter = THREE.LinearFilter;
sceneComposerTarget.texture.magFilter = THREE.LinearFilter;
composer = new EffectComposer(renderer, sceneComposerTarget);
composer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
sceneRenderPass = new RenderPass(scene, camera);
composer.addPass(sceneRenderPass);
sceneAoPass = new SSAOPass(scene, camera, 1, 1, 64);
sceneAoPass.normalMaterial.side = THREE.DoubleSide;
sceneAoPass.kernelRadius = 0.48;
sceneAoPass.minDistance = 0.00025;
sceneAoPass.maxDistance = 0.065;
if (tableDebugName === 'ao' && SSAOPass.OUTPUT?.Blur !== undefined) {
sceneAoPass.output = SSAOPass.OUTPUT.Blur;
} else if (SSAOPass.OUTPUT?.Default !== undefined) {
sceneAoPass.output = SSAOPass.OUTPUT.Default;
}
const renderAoPass = sceneAoPass.render.bind(sceneAoPass);
sceneAoPass.render = (...args) => {
aoExcludedObjects.forEach((object) => {
object.userData.wasVisibleForAo = object.visible;
object.visible = false;
});
try {
renderAoPass(...args);
} finally {
aoExcludedObjects.forEach((object) => {
object.visible = object.userData.wasVisibleForAo;
delete object.userData.wasVisibleForAo;
});
}
};
composer.addPass(sceneAoPass);
sceneSmaaPass = new SMAAPass(1, 1);
composer.addPass(sceneSmaaPass);
sceneOutputPass = new OutputPass();
composer.addPass(sceneOutputPass);
}
function buildLighting() {
scene.add(new THREE.AmbientLight(0x120b06, 0.22));
candleBounceLight = new THREE.HemisphereLight(0x4a2a14, 0x080403, 0.3);
scene.add(candleBounceLight);
addCandle(-2.38, 0.0, -0.55, 2.35, 0.62);
addCandle(2.2, 0.0, -1.34, 1.85, 0.38);
addCandle(2.36, 0.0, 0.62, 1.5, 0.48);
}
function addCandle(x, y, z, intensity, height) {
const candle = new THREE.Group();
candle.position.set(x, y, z);
const waxMaterial = createWaxMaterial(height);
const wax = new THREE.Mesh(
new THREE.CylinderGeometry(0.12, 0.12, height, 32),
waxMaterial
);
wax.position.y = height / 2 - 0.05;
wax.castShadow = false;
wax.receiveShadow = false;
candle.add(wax);
const waxGlow = new THREE.Mesh(
new THREE.CylinderGeometry(0.126, 0.126, height * 0.98, 32),
new THREE.MeshBasicMaterial({
color: 0xffc579,
transparent: true,
opacity: 0.045,
depthWrite: false
})
);
waxGlow.position.copy(wax.position);
waxGlow.castShadow = false;
waxGlow.receiveShadow = false;
waxGlow.userData.excludeFromAo = true;
aoExcludedObjects.add(waxGlow);
candle.add(waxGlow);
const wickTopY = height + 0.075;
const wick = new THREE.Mesh(
new THREE.CylinderGeometry(0.012, 0.009, 0.16, 10),
new THREE.MeshStandardMaterial({
color: 0x1a0f08,
roughness: 0.92,
metalness: 0,
emissive: 0x2a1206,
emissiveIntensity: 0.24
})
);
wick.position.y = height + 0.015;
wick.rotation.x = 0.16;
wick.castShadow = false;
wick.receiveShadow = false;
candle.add(wick);
const flame = createFlame();
flame.position.y = wickTopY + 0.055;
flame.userData.excludeFromAo = true;
flame.traverse((child) => {
child.userData.excludeFromAo = true;
aoExcludedObjects.add(child);
});
candle.add(flame);
const baseLightIntensity = intensity * 7.4;
const light = new THREE.PointLight(0xff9f45, baseLightIntensity, 4.35, 1.86);
light.position.copy(flame.position);
light.castShadow = false;
light.shadow.mapSize.set(2048, 2048);
light.shadow.bias = -0.00004;
light.shadow.normalBias = 0.018;
light.shadow.radius = 7;
light.shadow.blurSamples = 16;
light.shadow.camera.near = 0.04;
light.shadow.camera.far = 5.0;
candle.add(light);
candle.userData = {
light,
flame,
wax,
waxMaterial,
waxGlow,
bodyRadius: 0.12,
bodyHeight: height,
baseIntensity: baseLightIntensity,
seed: Math.random() * 100
};
candleShadowSources.push(candle);
scene.add(candle);
}
function createFlame() {
const flame = new THREE.Group();
const outer = new THREE.Mesh(
createFlameGeometry(0.07, 0.2, 28, 18),
createFlameMaterial({
base: new THREE.Color(0x342100),
middle: new THREE.Color(0xff9a20),
tip: new THREE.Color(0xffd271),
opacity: 0.58,
noiseScale: 16,
displacement: 0.015
})
);
const core = new THREE.Mesh(
createFlameGeometry(0.034, 0.145, 24, 14),
createFlameMaterial({
base: new THREE.Color(0x203258),
middle: new THREE.Color(0xfff2a8),
tip: new THREE.Color(0xffb54a),
opacity: 0.82,
noiseScale: 21,
displacement: 0.008
})
);
outer.renderOrder = 4;
core.renderOrder = 5;
flame.add(outer, core);
return flame;
}
function createFlameGeometry(radius, height, radialSegments, heightSegments) {
const positions = [];
const normals = [];
const uvs = [];
const indices = [];
for (let y = 0; y <= heightSegments; y += 1) {
const v = y / heightSegments;
const taper = Math.sin(Math.PI * Math.pow(v, 0.72));
const point = Math.pow(v, 3.2);
const ringRadius = radius * taper * (1 - point * 0.82) * (0.72 + v * 0.5);
const yPos = (v - 0.18) * height;
for (let x = 0; x <= radialSegments; x += 1) {
const u = x / radialSegments;
const angle = u * Math.PI * 2;
const lean = v * v * 0.018;
positions.push(
Math.cos(angle) * ringRadius + lean,
yPos,
Math.sin(angle) * ringRadius
);
normals.push(Math.cos(angle), 0.35, Math.sin(angle));
uvs.push(u, v);
}
}
for (let y = 0; y < heightSegments; y += 1) {
for (let x = 0; x < radialSegments; x += 1) {
const a = y * (radialSegments + 1) + x;
const b = a + 1;
const c = a + radialSegments + 1;
const d = c + 1;
indices.push(a, c, b, b, c, d);
}
}
const geometry = new THREE.BufferGeometry();
geometry.setIndex(indices);
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
geometry.computeVertexNormals();
return geometry;
}
function createFlameMaterial({ base, middle, tip, opacity, noiseScale, displacement }) {
return new THREE.ShaderMaterial({
transparent: true,
depthWrite: false,
depthTest: true,
blending: THREE.AdditiveBlending,
uniforms: {
time: { value: 0 },
baseColor: { value: base },
middleColor: { value: middle },
tipColor: { value: tip },
flameOpacity: { value: opacity },
noiseScale: { value: noiseScale },
displacement: { value: displacement }
},
vertexShader: `
uniform float time;
uniform float noiseScale;
uniform float displacement;
varying vec2 vUv;
varying float vHeight;
float wave(vec3 p) {
return sin(p.x * noiseScale + time * 7.1) * 0.5 +
sin((p.z + p.y) * noiseScale * 0.73 - time * 5.4) * 0.5;
}
void main() {
vUv = uv;
vHeight = uv.y;
vec3 transformed = position;
float flutter = wave(position) * displacement * smoothstep(0.08, 1.0, uv.y);
transformed.x += flutter;
transformed.z += sin(time * 4.9 + position.y * 23.0) * displacement * 0.35 * uv.y;
gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0);
}
`,
fragmentShader: `
uniform float time;
uniform vec3 baseColor;
uniform vec3 middleColor;
uniform vec3 tipColor;
uniform float flameOpacity;
varying vec2 vUv;
varying float vHeight;
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
void main() {
float alphaShape = smoothstep(0.0, 0.18, vHeight) * smoothstep(1.0, 0.58, vHeight);
float flicker = 0.88 + sin(time * 9.0 + hash(vUv) * 6.2831) * 0.08;
vec3 lower = mix(baseColor, middleColor, smoothstep(0.08, 0.5, vHeight));
vec3 color = mix(lower, tipColor, smoothstep(0.54, 1.0, vHeight));
gl_FragColor = vec4(color * flicker, alphaShape * flameOpacity);
}
`
});
}
function createWaxMaterial(height) {
const material = new THREE.MeshPhysicalMaterial({
color: 0xffdfaa,
roughness: 0.52,
metalness: 0,
transmission: 0.46,
thickness: 0.42,
attenuationColor: 0xffb76a,
attenuationDistance: 0.62,
ior: 1.42,
emissive: 0xffb56a,
emissiveIntensity: 0.055,
envMapIntensity: 0
});
material.customProgramCacheKey = () => `book-lab-wax-flame-aware-sss-${height.toFixed(3)}`;
material.onBeforeCompile = (shader) => {
material.userData.shader = shader;
shader.uniforms.waxHeight = { value: height };
shader.uniforms.waxFlameWorldPosition = { value: new THREE.Vector3(0, height + 0.12, 0) };
shader.uniforms.waxBodyWorldPosition = { value: new THREE.Vector3() };
shader.uniforms.waxLightPower = { value: 1 };
shader.vertexShader = shader.vertexShader
.replace(
'#include <common>',
'#include <common>\nvarying float vWaxLocalY;\nvarying vec3 vWaxWorldPosition;\nvarying vec3 vWaxWorldNormal;'
)
.replace(
'#include <begin_vertex>',
'#include <begin_vertex>\nvWaxLocalY = position.y;'
)
.replace(
'#include <defaultnormal_vertex>',
'#include <defaultnormal_vertex>\nvWaxWorldNormal = normalize(mat3(modelMatrix) * objectNormal);'
)
.replace(
'#include <project_vertex>',
'vWaxWorldPosition = (modelMatrix * vec4(transformed, 1.0)).xyz;\n#include <project_vertex>'
);
shader.fragmentShader = shader.fragmentShader
.replace(
'#include <common>',
`#include <common>
uniform float waxHeight;
uniform vec3 waxFlameWorldPosition;
uniform vec3 waxBodyWorldPosition;
uniform float waxLightPower;
varying float vWaxLocalY;
varying vec3 vWaxWorldPosition;
varying vec3 vWaxWorldNormal;`
)
.replace(
'#include <opaque_fragment>',
`float waxTop = smoothstep(waxHeight * 0.08, waxHeight * 0.5, vWaxLocalY);
float waxRim = pow(1.0 - abs(dot(normalize(normal), normalize(geometryViewDir))), 2.2);
float waxCore = smoothstep(-waxHeight * 0.45, waxHeight * 0.3, vWaxLocalY);
float waxFlameCup = smoothstep(waxHeight * 0.28, waxHeight * 0.52, vWaxLocalY);
vec3 waxToFlame = waxFlameWorldPosition - vWaxWorldPosition;
float waxFlameDistance = length(waxToFlame);
vec3 waxLightDir = normalize(waxToFlame);
vec3 waxWorldNormal = normalize(vWaxWorldNormal);
float waxNearFlame = 1.0 - smoothstep(0.06, 0.58, waxFlameDistance);
float waxUpperBody = smoothstep(waxBodyWorldPosition.y + waxHeight * 0.38, waxBodyWorldPosition.y + waxHeight * 0.92, vWaxWorldPosition.y);
float waxForwardScatter = pow(max(dot(waxWorldNormal, waxLightDir), 0.0), 0.62);
float waxBackScatter = pow(max(dot(-waxWorldNormal, waxLightDir), 0.0), 1.5);
float waxSideGlow = pow(max(1.0 - abs(dot(waxWorldNormal, waxLightDir)), 0.0), 1.15);
float waxSubsurface = (waxNearFlame * (0.56 + waxForwardScatter * 0.42) + waxBackScatter * 0.25 + waxSideGlow * 0.18) * waxUpperBody * waxLightPower;
float waxCavityGlow = waxFlameCup * waxNearFlame * waxLightPower * 0.75;
vec3 waxScatter = vec3(1.0, 0.48, 0.19) * (waxTop * 0.11 + waxRim * waxCore * 0.09 + waxFlameCup * 0.08 + waxSubsurface * 0.46 + waxCavityGlow * 0.36);
outgoingLight += waxScatter;
#include <opaque_fragment>`
);
};
return material;
}
function configureTableShader(material) {
material.customProgramCacheKey = () => 'book-lab-table-planar-environment-reflection-v7';
material.onBeforeCompile = (shader) => {
tableShader = shader;
shader.uniforms.roomReflectionMap = { value: tableRoomReflectionTexture };
shader.uniforms.sceneReflectionMap = { value: tableReflectionTarget.texture };
shader.uniforms.sceneReflectionMatrix = { value: tableReflectionMatrix };
shader.uniforms.tableDustMap = { value: tableDustTexture };
shader.uniforms.tableGreaseMap = { value: tableGreaseTexture };
shader.uniforms.candleBodyPositions = {
value: [new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3()]
};
shader.uniforms.candleFlamePositions = {
value: [new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3()]
};
shader.uniforms.candleBodyData = {
value: [new THREE.Vector2(), new THREE.Vector2(), new THREE.Vector2()]
};
shader.uniforms.bookShadowMaps = { value: bookShadowTargets.map((target) => target.texture) };
shader.uniforms.bookShadowMatrices = { value: bookShadowMatrices };
shader.uniforms.bookShadowMapTexelSize = { value: new THREE.Vector2(1 / bookShadowMapSize, 1 / bookShadowMapSize) };
shader.uniforms.tableDebugMode = { value: tableDebugMode };
shader.vertexShader = shader.vertexShader
.replace(
'#include <common>',
'#include <common>\nuniform mat4 sceneReflectionMatrix;\nvarying vec3 vTableWorldPosition;\nvarying vec4 vSceneReflectionCoord;'
)
.replace(
'#include <project_vertex>',
'vec4 tableWorldPosition = modelMatrix * vec4(transformed, 1.0);\nvTableWorldPosition = tableWorldPosition.xyz;\nvSceneReflectionCoord = sceneReflectionMatrix * tableWorldPosition;\n#include <project_vertex>'
);
shader.fragmentShader = shader.fragmentShader
.replace(
'#include <common>',
`#include <common>
uniform sampler2D roomReflectionMap;
uniform sampler2D sceneReflectionMap;
uniform sampler2D tableDustMap;
uniform sampler2D tableGreaseMap;
uniform mat4 sceneReflectionMatrix;
uniform vec3 candleBodyPositions[3];
uniform vec3 candleFlamePositions[3];
uniform vec2 candleBodyData[3];
uniform sampler2D bookShadowMaps[3];
uniform mat4 bookShadowMatrices[3];
uniform vec2 bookShadowMapTexelSize;
uniform int tableDebugMode;
varying vec3 vTableWorldPosition;
varying vec4 vSceneReflectionCoord;
vec2 tableDustUvFromWorld(vec3 worldPosition) {
return clamp(vec2(worldPosition.x / 9.8 + 0.5, 0.5 - worldPosition.z / 6.6), vec2(0.0), vec2(1.0));
}
float tableTopMaskFromWorld(vec3 worldPosition) {
return smoothstep(-0.095, -0.025, worldPosition.y);
}
float tableDustFromWorld(vec3 worldPosition) {
return texture2D(tableDustMap, tableDustUvFromWorld(worldPosition)).r * tableTopMaskFromWorld(worldPosition);
}
float tableGreaseFromWorld(vec3 worldPosition) {
return texture2D(tableGreaseMap, tableDustUvFromWorld(worldPosition)).r * tableTopMaskFromWorld(worldPosition);
}
vec3 rotateRoomReflection(vec3 dir) {
const float yaw = 0.42;
float s = sin(yaw);
float c = cos(yaw);
return normalize(vec3(c * dir.x - s * dir.z, dir.y, s * dir.x + c * dir.z));
}
vec3 sampleRoomReflection(vec3 dir) {
dir = rotateRoomReflection(normalize(dir));
float u = 0.5 + atan(dir.z, dir.x) / 6.28318530718;
float v = 0.5 + asin(clamp(dir.y, -1.0, 1.0)) / 3.14159265359;
return texture2D(roomReflectionMap, vec2(u, v)).rgb;
}
vec3 sampleRoughRoomReflection(vec3 dir) {
vec3 tangent = normalize(cross(vec3(0.0, 1.0, 0.0), dir));
if (length(tangent) < 0.01) tangent = vec3(1.0, 0.0, 0.0);
vec3 bitangent = normalize(cross(dir, tangent));
return sampleRoomReflection(dir) * 0.42 +
sampleRoomReflection(dir + tangent * 0.035) * 0.18 +
sampleRoomReflection(dir - tangent * 0.035) * 0.18 +
sampleRoomReflection(dir + bitangent * 0.026) * 0.11 +
sampleRoomReflection(dir - bitangent * 0.026) * 0.11;
}
float candleBodyOcclusion(vec3 point, vec3 flame, vec3 body, vec2 bodyData, float selfLight) {
vec3 segment = point - flame;
vec2 segmentXZ = segment.xz;
vec2 flameToBody = flame.xz - body.xz;
float radius = bodyData.x;
float a = max(dot(segmentXZ, segmentXZ), 0.000001);
float b = 2.0 * dot(flameToBody, segmentXZ);
float c = dot(flameToBody, flameToBody) - radius * radius;
float discriminant = b * b - 4.0 * a * c;
float nearestT = clamp(-dot(flameToBody, segmentXZ) / a, 0.0, 1.0);
vec2 nearestXZ = flameToBody + segmentXZ * nearestT;
float nearestDistance = length(nearestXZ);
float hitT = nearestT;
float cylinderHit = 0.0;
if (discriminant > 0.0) {
float sqrtDisc = sqrt(discriminant);
float t0 = (-b - sqrtDisc) / (2.0 * a);
float t1 = (-b + sqrtDisc) / (2.0 * a);
float validT0 = step(0.0, t0) * step(t0, 1.0);
float validT1 = step(0.0, t1) * step(t1, 1.0);
hitT = mix(hitT, t0, validT0);
hitT = mix(hitT, t1, (1.0 - validT0) * validT1);
cylinderHit = max(validT0, validT1);
}
float closestY = flame.y + segment.y * hitT;
float bodyTop = body.y + bodyData.y;
float vertical = smoothstep(body.y - 0.045, body.y + 0.08, closestY) *
(1.0 - smoothstep(bodyTop - 0.08, bodyTop + 0.045, closestY));
float segmentLength = length(segment);
float penumbraWidth = radius * (0.45 + segmentLength * 0.12);
float exactHit = cylinderHit;
float softHit = 1.0 - smoothstep(radius, radius + penumbraWidth, nearestDistance);
float selfShadowLimiter = mix(1.0, 0.06, selfLight);
float waxExitHeight = smoothstep(body.y, bodyTop, closestY);
float waxTransmission = 0.48 + 0.34 * waxExitHeight;
float bodyOpacity = 1.0 - waxTransmission * mix(0.62, 0.86, selfLight);
return clamp(max(exactHit, softHit * 0.72) * vertical * selfShadowLimiter * bodyOpacity, 0.0, 0.42);
}
float candleProjectedShadowField(vec3 point) {
float projectedShadow = 0.0;
for (int bodyIndex = 0; bodyIndex < 3; bodyIndex++) {
for (int flameIndex = 0; flameIndex < 3; flameIndex++) {
float selfLight = bodyIndex == flameIndex ? 1.0 : 0.0;
projectedShadow = max(projectedShadow, candleBodyOcclusion(point, candleFlamePositions[flameIndex], candleBodyPositions[bodyIndex], candleBodyData[bodyIndex], selfLight));
}
}
return clamp(projectedShadow, 0.0, 0.46);
}
float candlePlanarShadowLobe(vec3 point, vec3 flame, vec3 body, vec2 bodyData, float selfLight) {
vec2 lightToBody = body.xz - flame.xz;
float lightDistance = length(lightToBody);
vec2 direction = lightDistance > 0.012 ? lightToBody / lightDistance : normalize(vec2(0.42, 0.91));
vec2 perpendicular = vec2(-direction.y, direction.x);
vec2 delta = point.xz - body.xz;
float along = dot(delta, direction);
float side = abs(dot(delta, perpendicular));
float radius = bodyData.x;
float softContact = 1.0 - smoothstep(radius * 0.72, radius * 3.4, length(delta));
float lobeLength = mix(radius * 3.4, radius * (4.8 + lightDistance * 0.92), 1.0 - selfLight);
float lobeWidth = radius * (1.6 + max(along, 0.0) * mix(0.72, 0.46, selfLight));
float frontGate = smoothstep(-radius * 0.42, radius * 0.34, along);
float distanceFade = 1.0 - smoothstep(radius * 0.7, lobeLength, along);
float sideFade = 1.0 - smoothstep(lobeWidth * 0.54, lobeWidth, side);
float directional = frontGate * distanceFade * sideFade;
float heightFade = smoothstep(body.y - 0.02, body.y + bodyData.y * 0.72, flame.y);
float waxTransmission = mix(0.54, 0.78, selfLight);
float strength = mix(0.32, 0.2, selfLight) * heightFade * (1.0 - waxTransmission * 0.48);
return clamp(max(softContact * 0.2, directional * strength), 0.0, 0.38);
}
float candlePlanarShadowField(vec3 point) {
float shadow = 0.0;
for (int bodyIndex = 0; bodyIndex < 3; bodyIndex++) {
for (int flameIndex = 0; flameIndex < 3; flameIndex++) {
float selfLight = bodyIndex == flameIndex ? 1.0 : 0.0;
shadow = max(shadow, candlePlanarShadowLobe(point, candleFlamePositions[flameIndex], candleBodyPositions[bodyIndex], candleBodyData[bodyIndex], selfLight));
}
}
return clamp(shadow, 0.0, 0.5);
}
float bookUnpackRGBADepth(vec4 packedDepth) {
const vec4 unpackFactors = vec4(
1.0 / (256.0 * 256.0 * 256.0),
1.0 / (256.0 * 256.0),
1.0 / 256.0,
1.0
);
return dot(packedDepth, unpackFactors);
}
float bookShadowCompare(vec4 packedDepth, float currentDepth) {
float closestDepth = bookUnpackRGBADepth(packedDepth);
return smoothstep(0.001, 0.018, currentDepth - closestDepth - 0.0018);
}
float bookShadowSample0(vec4 shadowCoord) {
vec3 coord = shadowCoord.xyz / max(shadowCoord.w, 0.0001);
float inBounds = step(0.0, coord.x) * step(0.0, coord.y) *
step(coord.x, 1.0) * step(coord.y, 1.0) * step(0.0, coord.z) * step(coord.z, 1.0);
if (inBounds < 0.5) return 0.0;
float shadow = 0.0;
for (int x = -1; x <= 1; x++) {
for (int y = -1; y <= 1; y++) {
vec2 offset = vec2(float(x), float(y)) * bookShadowMapTexelSize * 1.65;
shadow += bookShadowCompare(texture2D(bookShadowMaps[0], coord.xy + offset), coord.z);
}
}
return clamp(shadow / 9.0, 0.0, 1.0) * inBounds;
}
float bookShadowSample1(vec4 shadowCoord) {
vec3 coord = shadowCoord.xyz / max(shadowCoord.w, 0.0001);
float inBounds = step(0.0, coord.x) * step(0.0, coord.y) *
step(coord.x, 1.0) * step(coord.y, 1.0) * step(0.0, coord.z) * step(coord.z, 1.0);
if (inBounds < 0.5) return 0.0;
float shadow = 0.0;
for (int x = -1; x <= 1; x++) {
for (int y = -1; y <= 1; y++) {
vec2 offset = vec2(float(x), float(y)) * bookShadowMapTexelSize * 1.65;
shadow += bookShadowCompare(texture2D(bookShadowMaps[1], coord.xy + offset), coord.z);
}
}
return clamp(shadow / 9.0, 0.0, 1.0) * inBounds;
}
float bookShadowSample2(vec4 shadowCoord) {
vec3 coord = shadowCoord.xyz / max(shadowCoord.w, 0.0001);
float inBounds = step(0.0, coord.x) * step(0.0, coord.y) *
step(coord.x, 1.0) * step(coord.y, 1.0) * step(0.0, coord.z) * step(coord.z, 1.0);
if (inBounds < 0.5) return 0.0;
float shadow = 0.0;
for (int x = -1; x <= 1; x++) {
for (int y = -1; y <= 1; y++) {
vec2 offset = vec2(float(x), float(y)) * bookShadowMapTexelSize * 1.65;
shadow += bookShadowCompare(texture2D(bookShadowMaps[2], coord.xy + offset), coord.z);
}
}
return clamp(shadow / 9.0, 0.0, 1.0) * inBounds;
}
float bookMeshShadowField(vec3 point) {
float shadow0 = bookShadowSample0(bookShadowMatrices[0] * vec4(point, 1.0));
float shadow1 = bookShadowSample1(bookShadowMatrices[1] * vec4(point, 1.0));
float shadow2 = bookShadowSample2(bookShadowMatrices[2] * vec4(point, 1.0));
return clamp(max(max(shadow0, shadow1), shadow2) * 0.62, 0.0, 0.62);
}`
)
.replace(
'#include <roughnessmap_fragment>',
`#include <roughnessmap_fragment>
float tableSpecularGrease = tableGreaseFromWorld(vTableWorldPosition);
float tableSpecularDust = tableDustFromWorld(vTableWorldPosition) * (1.0 - smoothstep(0.025, 0.18, tableSpecularGrease) * 0.82);
float tableSpecularDustFilm = smoothstep(0.006, 0.034, tableSpecularDust);
float tableSpecularGreaseFilm = smoothstep(0.016, 0.14, tableSpecularGrease);
roughnessFactor = clamp(mix(roughnessFactor, 0.84, tableSpecularDustFilm * 0.32), 0.04, 1.0);
roughnessFactor = clamp(mix(roughnessFactor, 0.34, tableSpecularGreaseFilm * 0.3), 0.04, 1.0);`
)
.replace(
'#include <opaque_fragment>',
`vec3 viewDirWorld = normalize(cameraPosition - vTableWorldPosition);
vec3 tableNormalWorld = normalize(vec3(normal.x * 0.18, 1.0, normal.z * 0.18));
vec3 reflectedDir = reflect(-viewDirWorld, tableNormalWorld);
vec3 roomReflection = sampleRoughRoomReflection(reflectedDir);
roomReflection = pow(max(roomReflection, vec3(0.0)), vec3(0.78));
roomReflection *= vec3(0.88, 0.7, 0.5);
vec2 sceneReflectionUv = vSceneReflectionCoord.xy / max(vSceneReflectionCoord.w, 0.0001);
float sceneReflectionInBounds = step(0.0, sceneReflectionUv.x) * step(0.0, sceneReflectionUv.y) *
step(sceneReflectionUv.x, 1.0) * step(sceneReflectionUv.y, 1.0);
float sceneReflectionEdge = smoothstep(0.0, 0.08, sceneReflectionUv.x) *
smoothstep(0.0, 0.08, sceneReflectionUv.y) *
smoothstep(0.0, 0.08, 1.0 - sceneReflectionUv.x) *
smoothstep(0.0, 0.08, 1.0 - sceneReflectionUv.y);
vec3 sceneReflection = texture2D(sceneReflectionMap, sceneReflectionUv).rgb;
sceneReflection = pow(max(sceneReflection, vec3(0.0)), vec3(0.88)) * sceneReflectionInBounds * sceneReflectionEdge;
float grease = tableGreaseFromWorld(vTableWorldPosition);
float greaseDustWipe = smoothstep(0.016, 0.13, grease);
float dust = tableDustFromWorld(vTableWorldPosition) * (1.0 - greaseDustWipe * 0.82);
float dustFilm = smoothstep(0.006, 0.034, dust);
float greaseFilm = smoothstep(0.016, 0.14, grease);
float reflectionCleanliness = 1.0 - dustFilm * 0.2 - greaseFilm * 0.06;
vec3 dustBlurredSceneReflection = sceneReflection * 0.78 + roomReflection * 0.055;
vec3 greaseBlurredSceneReflection = sceneReflection * 0.7 + roomReflection * 0.18;
vec3 dustAwareSceneReflection = mix(sceneReflection, dustBlurredSceneReflection, dustFilm);
dustAwareSceneReflection = mix(dustAwareSceneReflection, greaseBlurredSceneReflection, greaseFilm);
vec3 combinedReflection = (roomReflection * 0.14 + dustAwareSceneReflection * 0.86) * reflectionCleanliness;
float fresnel = pow(1.0 - max(dot(viewDirWorld, tableNormalWorld), 0.0), 1.85);
float tableReflectionMask = smoothstep(-0.095, -0.025, vTableWorldPosition.y);
vec3 reflectedSurface = combinedReflection * (0.64 + fresnel * 0.36);
float reflectedLuma = dot(reflectedSurface, vec3(0.299, 0.587, 0.114));
reflectedSurface = mix(reflectedSurface, vec3(reflectedLuma), dustFilm * 0.42);
float candleProjectedShadow = max(
max(candleProjectedShadowField(vTableWorldPosition), candlePlanarShadowField(vTableWorldPosition)),
bookMeshShadowField(vTableWorldPosition)
) * tableReflectionMask;
float candleOcclusion = clamp(candleProjectedShadow * 1.46, 0.0, 0.82);
vec3 normalDebug = normalize(normal) * 0.5 + 0.5;
outgoingLight = mix(outgoingLight, reflectedSurface, tableReflectionMask * (0.16 + fresnel * 0.28 + greaseFilm * 0.065) * reflectionCleanliness);
outgoingLight += tableReflectionMask * roomReflection * 0.004 * reflectionCleanliness;
outgoingLight += tableReflectionMask * dustFilm * vec3(0.008, 0.0085, 0.009) * (0.22 + fresnel * 0.62);
outgoingLight += tableReflectionMask * greaseFilm * vec3(0.018, 0.013, 0.007) * (0.28 + fresnel * 0.58);
outgoingLight *= mix(vec3(1.0), vec3(0.19, 0.15, 0.115), candleOcclusion);
if (tableDebugMode == 1) outgoingLight = vec3(candleProjectedShadow);
if (tableDebugMode == 2) outgoingLight = vec3(dust);
if (tableDebugMode == 3) outgoingLight = normalDebug;
if (tableDebugMode == 4) outgoingLight = roomReflection;
if (tableDebugMode == 5) outgoingLight = sceneReflection;
if (tableDebugMode == 6) outgoingLight = vec3(tableReflectionMask);
if (tableDebugMode == 8) outgoingLight = vec3(grease);
if (tableDebugMode == 10) outgoingLight = combinedReflection;
#include <opaque_fragment>`
);
};
}
function buildBook() {
clearActiveFlips();
book.traverse((object) => {
if (object.isMesh) aoExcludedObjects.delete(object);
});
book.clear();
book.position.set(0, tableTopY + bookTableContactClearance, 0);
book.rotation.y = 0;
const proceduralBook = createProceduralBookModel({
readingProgress,
pageCount: bookPageCount,
maxAnisotropy: maxTextureAnisotropy,
materials: {
cover: materials.leather,
hinge: materials.hingeLeather,
coverSpineBase: materials.spineBaseLeather,
coverEdge: materials.coverEdge,
spine: materials.spineCloth,
headband: materials.headband,
pageTop: materials.pageSurface,
leftPage: materials.leftPage,
rightPage: materials.rightPage
},
configureMaterial(material, part) {
if (part === 'pages') {
configureHardcoverPaperMaterial(material, { useEdgeMap: material.map !== null });
}
const strength = part === 'spine'
? 0.48
: part === 'headband'
? 0.62
: part === 'coverSpineBase'
? 0.34
: part === 'hinge'
? 0.36
: part === 'cover'
? 0.52
: part === 'coverEdge'
? 0.28
: 0.42;
configureBookShadowReceiver(material, strength);
}
});
currentProceduralBookModel = proceduralBook.model;
book.add(proceduralBook.group);
}
function configureHardcoverPaperMaterial(material, { useEdgeMap = false } = {}) {
material.userData.isHardcoverPaper = true;
if (!material.map) material.map = useEdgeMap ? paperTextures.edge : paperTextures.color;
material.normalMap = paperTextures.normal;
material.normalScale = material.normalScale ?? new THREE.Vector2(0.024, 0.024);
material.roughnessMap = paperTextures.roughness;
material.roughness = Math.max(material.roughness ?? 0.86, useEdgeMap ? 0.92 : 0.86);
material.metalness = 0;
material.envMapIntensity = Math.min(material.envMapIntensity ?? 0.05, 0.06);
material.needsUpdate = true;
}
function setReadingProgress(value) {
const nextProgress = THREE.MathUtils.clamp(Number.parseFloat(value), 0, 1);
if (!Number.isFinite(nextProgress)) return;
readingProgress = nextProgress;
buildBook();
syncBookControls();
window.WebGLBookPreferenceBridge?.updateProgress?.(readingProgress);
}
function setBookPageCount(value) {
const nextPageCount = snapProceduralPageCount(value);
if (!Number.isFinite(nextPageCount)) return;
bookPageCount = nextPageCount;
buildBook();
syncBookControls();
window.WebGLBookPreferenceBridge?.updatePageCount?.(bookPageCount);
}
function stepReadingProgress(pageDelta) {
setReadingProgress(readingProgress + pageDelta / Math.max(1, bookPageCount));
}
function installBookControls() {
if (!progressInput || !pageCountInput) return;
progressInput.value = readingProgress.toFixed(3);
pageCountInput.min = String(PROCEDURAL_BOOK.PAGE_COUNT_MIN);
pageCountInput.max = String(PROCEDURAL_BOOK.PAGE_COUNT_MAX);
pageCountInput.step = String(PROCEDURAL_BOOK.PAGE_COUNT_STEP);
pageCountInput.value = String(bookPageCount);
progressInput.addEventListener('input', () => setReadingProgress(progressInput.value));
pageCountInput.addEventListener('input', () => setBookPageCount(pageCountInput.value));
backwardButton?.addEventListener('click', () => startPageFlip(-1));
forwardButton?.addEventListener('click', () => startPageFlip(1));
fastBackwardButton?.addEventListener('click', () => startFastPageFlip(-1));
fastForwardButton?.addEventListener('click', () => startFastPageFlip(1));
syncBookControls();
}
function syncBookControls() {
const busy = activeFlips.length > 0;
if (progressInput) progressInput.value = readingProgress.toFixed(3);
if (progressValue) progressValue.textContent = readingProgress.toFixed(2);
if (pageCountInput) pageCountInput.value = String(bookPageCount);
if (pageCountValue) pageCountValue.textContent = String(bookPageCount);
if (backwardButton) backwardButton.disabled = busy || !canPageFlip(-1);
if (fastBackwardButton) fastBackwardButton.disabled = busy || !canPageFlip(-1);
if (forwardButton) forwardButton.disabled = busy || !canPageFlip(1);
if (fastForwardButton) fastForwardButton.disabled = busy || !canPageFlip(1);
}
function handlePageCanvases(event) {
const detail = event.detail || {};
if (detail.left) {
drawCanvasPageTexture(leftCanvas, detail.left, 'left');
leftTexture.needsUpdate = true;
}
if (detail.right) {
drawCanvasPageTexture(rightCanvas, detail.right, 'right');
rightTexture.needsUpdate = true;
}
document.documentElement.dataset.webglPageTextureMetrics = JSON.stringify({
width: leftCanvas.width,
height: leftCanvas.height,
source: 'book-texture-renderer'
});
}
function drawCanvasPageTexture(canvas, sourceCanvas, side) {
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fffaf0';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const shade = ctx.createLinearGradient(0, 0, canvas.width, 0);
shade.addColorStop(0, 'rgba(93, 55, 24, 0.10)');
shade.addColorStop(side === 'left' ? 0.85 : 0.15, 'rgba(255, 255, 255, 0)');
shade.addColorStop(1, 'rgba(85, 49, 21, 0.08)');
ctx.fillStyle = shade;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(sourceCanvas, 0, 0, canvas.width, canvas.height);
updatePageTextureDebugState(side, canvas, sourceCanvas, true);
return true;
}
function getPageTextureDebugState() {
const rawState = document.documentElement.dataset.webglPageTextures;
if (!rawState) return {};
try {
return JSON.parse(rawState);
} catch (error) {
return {};
}
}
function updatePageTextureDebugState(side, canvas, source, painted) {
const state = getPageTextureDebugState();
state[side] = {
painted,
width: canvas.width,
height: canvas.height,
sourceId: source?.id || 'book-texture-renderer',
sourceTextLength: 0,
darkPixels: countPageTextureDarkPixels(canvas)
};
document.documentElement.dataset.webglPageTextures = JSON.stringify(state);
}
function countPageTextureDarkPixels(canvas) {
const sampleCanvas = document.createElement('canvas');
const sampleSize = 64;
sampleCanvas.width = sampleSize;
sampleCanvas.height = sampleSize;
const sampleContext = sampleCanvas.getContext('2d');
sampleContext.drawImage(canvas, 0, 0, sampleSize, sampleSize);
const pixels = sampleContext.getImageData(0, 0, sampleSize, sampleSize).data;
let darkPixels = 0;
for (let index = 0; index < pixels.length; index += 4) {
const alpha = pixels[index + 3];
if (alpha < 8) continue;
const luminance = pixels[index] * 0.2126 + pixels[index + 1] * 0.7152 + pixels[index + 2] * 0.0722;
if (luminance < 96) darkPixels += 1;
}
return darkPixels;
}
function projectPointerToPage(clientX, clientY) {
const rect = canvas.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return null;
pointerNdc.set(
((clientX - rect.left) / rect.width) * 2 - 1,
-(((clientY - rect.top) / rect.height) * 2 - 1)
);
pageRaycaster.setFromCamera(pointerNdc, camera);
const intersections = pageRaycaster.intersectObjects(book.children, true);
for (const hit of intersections) {
const pageSide = textureHitPageSide(hit);
if (!pageSide || !hit.uv) continue;
const mappedX = THREE.MathUtils.clamp(hit.uv.x, 0, 1);
const mappedY = 1 - THREE.MathUtils.clamp(hit.uv.y, 0, 1);
return {
pageId: pageSide === 'left' ? 'page_left' : 'page_right',
x: mappedX,
y: mappedY,
uv: { x: hit.uv.x, y: hit.uv.y }
};
}
return null;
}
function textureHitPageSide(hit) {
const material = Array.isArray(hit.object.material)
? hit.object.material[hit.face?.materialIndex ?? 0]
: hit.object.material;
if (material === materials.leftPage) return 'left';
if (material === materials.rightPage) return 'right';
if (material?.map === leftTexture) return 'left';
if (material?.map === rightTexture) return 'right';
return null;
}
function startPageFlip(direction) {
if (activeFlips.length || !currentProceduralBookModel || !canPageFlip(direction)) return false;
const flip = createPageFlip(direction, performance.now(), normalFlipDuration);
if (!flip) return false;
activeFlips.push(flip);
syncBookControls();
updateActiveFlips(flip.startTime);
return true;
}
function startFastPageFlip(direction) {
if (activeFlips.length || !currentProceduralBookModel || !canPageFlip(direction)) return false;
const firstFlip = createPageFlip(direction, performance.now(), fastFlipDuration);
if (!firstFlip) return false;
const startTime = firstFlip.startTime;
const interval = fastFlipDuration / fastFlipOverlap;
for (let index = 0; index < fastFlipCount; index += 1) {
activeFlips.push({
...firstFlip,
mesh: null,
startTime: startTime + index * interval,
pageOffset: index * 0.002,
commitBundleOnFinish: index === fastFlipCount - 1,
countAsPending: false
});
}
syncBookControls();
updateActiveFlips(startTime);
return true;
}
function createPageFlip(direction, startTime, duration) {
const sourceSide = direction > 0 ? 1 : -1;
const sourceLine = topVisibleLine(sourceSide);
const destinationLine = topVisibleLine(-sourceSide);
if (!sourceLine || !destinationLine) return null;
return {
direction,
sourceLine,
destinationLine,
startTime,
duration,
pageOffset: 0,
commitBundleOnFinish: false,
countAsPending: true,
mesh: null
};
}
function canPageFlip(direction) {
if (!currentProceduralBookModel) return false;
if (direction > 0) return readingProgress < 1;
return readingProgress > 0;
}
function topVisibleLine(side) {
const sideLines = currentProceduralBookModel.lines
.filter((line) => line.side === side)
.sort((a, b) => side < 0 ? a.t - b.t : b.t - a.t);
return sideLines[sideLines.length - 1] ?? null;
}
function updateActiveFlips(now) {
if (!activeFlips.length || !currentProceduralBookModel) return;
const completed = [];
activeFlips.forEach((flip) => {
const elapsed = (now - flip.startTime) / flip.duration;
if (elapsed < 0) return;
const t = THREE.MathUtils.clamp(elapsed, 0, 1);
const surface = buildFlippingPageSurface(flip.sourceLine, flip.destinationLine, flip.direction, easeInOutCubic(t), flip.pageOffset);
setActivePageGeometry(flip, surface);
if (t >= 1) completed.push(flip);
});
completed.forEach((flip) => finishActiveFlip(flip));
}
function buildFlippingPageSurface(sourceLine, destinationLine, direction, t, pageOffset = 0) {
const widthSegments = sourceLine.points.length - 1;
const depthSegments = 18;
const zFront = currentProceduralBookModel.pageDepth * 0.5;
const zBack = -currentProceduralBookModel.pageDepth * 0.5;
if (t <= 0) return createRestingPageSurface(sourceLine.points, depthSegments, zFront, zBack);
if (t >= 1) return createRestingPageSurface(destinationLine.points, depthSegments, zFront, zBack);
const anchor = {
x: THREE.MathUtils.lerp(sourceLine.anchor.x, destinationLine.anchor.x, t),
y: THREE.MathUtils.lerp(sourceLine.anchor.y, destinationLine.anchor.y, t)
};
const sourceSide = direction > 0 ? 1 : -1;
const startAngle = sourceSide > 0 ? 0 : Math.PI;
const baseAngle = startAngle + direction * Math.PI * t;
const lift = Math.sin(Math.PI * t);
const curlStrength = direction * 0.48 * lift;
const widthDistances = cumulativeLineLengths(sourceLine.points);
const surface = [];
for (let widthIndex = 0; widthIndex <= widthSegments; widthIndex += 1) {
const u = widthIndex / widthSegments;
const radius = widthDistances[widthIndex];
const row = [];
for (let depthIndex = 0; depthIndex <= depthSegments; depthIndex += 1) {
const v = depthIndex / depthSegments;
const z = THREE.MathUtils.lerp(zFront, zBack, v);
const depthWave = (v - 0.5) * 0.22 * lift * (0.15 + u * 0.85);
const curl = curlStrength * Math.sin(Math.PI * u) + direction * depthWave;
const angle = baseAngle + curl;
const stackPoint = interpolatePagePoint(sourceLine.points, destinationLine.points, widthIndex, t);
const flyingX = anchor.x + Math.cos(angle) * radius;
const relaxedY = THREE.MathUtils.lerp(stackPoint.y, anchor.y + Math.sin(angle) * radius, lift);
const point = {
x: THREE.MathUtils.lerp(stackPoint.x, flyingX, lift),
y: relaxedY + pageOffset + 0.055 * lift * Math.sin(Math.PI * u),
z
};
keepFlippingSurfacePointAboveStacks(point, lift);
row.push(point);
}
surface.push(row);
}
return surface;
}
function cumulativeLineLengths(points) {
const lengths = [0];
for (let index = 1; index < points.length; index += 1) {
const previous = points[index - 1];
const point = points[index];
lengths[index] = lengths[index - 1] + Math.hypot(point.x - previous.x, point.y - previous.y);
}
return lengths;
}
function createRestingPageSurface(points, depthSegments, zFront, zBack) {
return points.map((point) => {
const row = [];
for (let depthIndex = 0; depthIndex <= depthSegments; depthIndex += 1) {
row.push({
x: point.x,
y: point.y,
z: THREE.MathUtils.lerp(zFront, zBack, depthIndex / depthSegments)
});
}
return row;
});
}
function interpolatePagePoint(sourcePoints, destinationPoints, index, t) {
const source = sourcePoints[index];
const destination = destinationPoints[index];
return {
x: THREE.MathUtils.lerp(source.x, destination.x, t),
y: THREE.MathUtils.lerp(source.y, destination.y, t)
};
}
function keepFlippingSurfacePointAboveStacks(point, lift) {
const envelopeY = stackEnvelopeYAtX(point.x);
if (envelopeY === null) return;
const clearance = 0.016 + lift * 0.045;
point.y = Math.max(point.y, envelopeY + clearance);
}
function stackEnvelopeYAtX(x) {
let envelope = null;
currentProceduralBookModel.lines.forEach((line) => {
const y = lineYAtX(line.points, x);
if (y === null) return;
envelope = envelope === null ? y : Math.max(envelope, y);
});
return envelope;
}
function lineYAtX(points, x) {
let y = null;
for (let index = 0; index < points.length - 1; index += 1) {
const a = points[index];
const b = points[index + 1];
const minX = Math.min(a.x, b.x) - 0.00001;
const maxX = Math.max(a.x, b.x) + 0.00001;
if (x < minX || x > maxX) continue;
const span = b.x - a.x;
const segmentY = Math.abs(span) < 0.00001
? Math.max(a.y, b.y)
: THREE.MathUtils.lerp(a.y, b.y, (x - a.x) / span);
y = y === null ? segmentY : Math.max(y, segmentY);
}
return y;
}
function setActivePageGeometry(flip, surface) {
const geometry = createFlippingPageGeometry(surface);
if (!flip.mesh) {
flip.mesh = new THREE.Mesh(geometry, materials.flipPageSurface);
flip.mesh.castShadow = false;
flip.mesh.receiveShadow = false;
flip.mesh.userData.bookPart = 'flippingPage';
flip.mesh.userData.isProceduralBookMesh = true;
book.add(flip.mesh);
return;
}
flip.mesh.geometry.dispose();
flip.mesh.geometry = geometry;
}
function createFlippingPageGeometry(surface) {
const positions = [];
const uvs = [];
const indices = [];
const topGrid = [];
const bottomGrid = [];
const pageThickness = 0.006;
const widthSegments = surface.length - 1;
const depthSegments = surface[0].length - 1;
const push = (point, yOffset, u, v) => {
const index = positions.length / 3;
positions.push(point.x, point.y + yOffset, point.z);
uvs.push(u, v);
return index;
};
surface.forEach((rowPoints, widthIndex) => {
const topRow = [];
const bottomRow = [];
const u = widthSegments <= 0 ? 0 : widthIndex / widthSegments;
rowPoints.forEach((point, depthIndex) => {
const v = depthSegments <= 0 ? 0 : depthIndex / depthSegments;
topRow.push(push(point, pageThickness, u, v));
bottomRow.push(push(point, 0, u, v));
});
topGrid.push(topRow);
bottomGrid.push(bottomRow);
});
for (let index = 0; index < widthSegments; index += 1) {
for (let zIndex = 0; zIndex < depthSegments; zIndex += 1) {
const a = topGrid[index][zIndex];
const b = topGrid[index + 1][zIndex];
const c = topGrid[index][zIndex + 1];
const d = topGrid[index + 1][zIndex + 1];
const bottomA = bottomGrid[index][zIndex];
const bottomB = bottomGrid[index + 1][zIndex];
const bottomC = bottomGrid[index][zIndex + 1];
const bottomD = bottomGrid[index + 1][zIndex + 1];
indices.push(a, c, b, b, c, d);
indices.push(bottomA, bottomB, bottomC, bottomB, bottomD, bottomC);
}
}
for (let index = 0; index < widthSegments; index += 1) {
addWall(topGrid[index][0], topGrid[index + 1][0], bottomGrid[index][0], bottomGrid[index + 1][0]);
addWall(topGrid[index][depthSegments], topGrid[index + 1][depthSegments], bottomGrid[index][depthSegments], bottomGrid[index + 1][depthSegments]);
}
for (let zIndex = 0; zIndex < depthSegments; zIndex += 1) {
addWall(topGrid[0][zIndex], topGrid[0][zIndex + 1], bottomGrid[0][zIndex], bottomGrid[0][zIndex + 1]);
addWall(topGrid[widthSegments][zIndex], topGrid[widthSegments][zIndex + 1], bottomGrid[widthSegments][zIndex], bottomGrid[widthSegments][zIndex + 1]);
}
const geometry = new THREE.BufferGeometry();
geometry.setIndex(indices);
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
geometry.computeVertexNormals();
return geometry;
function addWall(topA, topB, bottomA, bottomB) {
indices.push(topA, bottomA, topB, topB, bottomA, bottomB);
}
}
function finishActiveFlip(flip) {
removeFlipMesh(flip);
activeFlips = activeFlips.filter((active) => active !== flip);
if (flip.commitBundleOnFinish) {
shiftReadingProgressByBundle(flip.direction);
return;
}
if (!flip.countAsPending) {
syncBookControls();
return;
}
pendingPageFlips += flip.direction;
if (Math.abs(pendingPageFlips) >= 10) {
const commitDirection = Math.sign(pendingPageFlips);
pendingPageFlips -= commitDirection * 10;
shiftReadingProgressByBundle(commitDirection);
return;
}
syncBookControls();
}
function shiftReadingProgressByBundle(direction) {
const step = 1 / Math.max(1, currentProceduralBookModel.bundleCount - 1);
setReadingProgress(readingProgress + direction * step);
}
function clearActiveFlips() {
activeFlips.forEach(removeFlipMesh);
activeFlips = [];
}
function removeFlipMesh(flip) {
if (!flip.mesh) return;
aoExcludedObjects.delete(flip.mesh);
book.remove(flip.mesh);
flip.mesh.geometry.dispose();
flip.mesh = null;
}
function easeInOutCubic(t) {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) * 0.5;
}
function makeBox(width, height, depth, material) {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(width, height, depth), material);
mesh.castShadow = false;
mesh.receiveShadow = false;
return mesh;
}
function progressPageTopY(side, thickness, u, v) {
const boundLift = 0.055 * Math.pow(1 - u, 2.2);
const foreEdgeSettle = -0.018 * Math.pow(u, 1.45);
const pageCrown = 0.014 * Math.sin(Math.PI * u) * (0.25 + 0.75 * Math.sin(Math.PI * v));
const foreEdgeIrregularity = 0.005 * Math.sin(v * Math.PI * 7.0 + side * 1.8) * Math.pow(u, 2.0);
return thickness + boundLift + foreEdgeSettle + pageCrown + foreEdgeIrregularity;
}
function pageSheetY(side, thickness, u, v) {
const gutterLift = 0.066 * Math.pow(1 - u, 2.05);
const edgeFall = -0.017 * Math.pow(u, 1.65);
const centerSag = -0.019 * Math.sin(Math.PI * v) * Math.sin(Math.PI * u);
const crown = 0.012 * Math.sin(Math.PI * u) * (0.35 + 0.65 * Math.sin(Math.PI * v));
return thickness + gutterLift + edgeFall + centerSag + crown;
}
function pageSheetPosition(side, width, height, thickness, gutterW, u, v, yOffset = 0) {
const outward = u * width;
const pageX = side * (gutterW + outward);
const ripple = 0.004 * Math.sin(v * Math.PI * 4 + side * 0.7) * (1 - Math.abs(u - 0.5));
return new THREE.Vector3(pageX, pageSheetY(side, thickness, u, v) + yOffset, (v - 0.5) * height + ripple);
}
function createVisiblePageGeometry(side, width, height, stackThickness, sheetThickness, gutterW) {
const columns = 36;
const rows = 42;
const positions = [];
const uvs = [];
const indices = [];
const pushVertex = (u, v, yOffset) => {
const point = pageSheetPosition(side, width, height, stackThickness, gutterW, u, v, yOffset);
positions.push(point.x, point.y, point.z);
uvs.push(side < 0 ? 1 - u : u, 1 - v);
};
for (let y = 0; y <= rows; y += 1) {
const v = y / rows;
for (let x = 0; x <= columns; x += 1) {
const u = x / columns;
pushVertex(u, v, sheetThickness * 0.5);
}
}
const bottomStart = positions.length / 3;
for (let y = 0; y <= rows; y += 1) {
const v = y / rows;
for (let x = 0; x <= columns; x += 1) {
const u = x / columns;
pushVertex(u, v, -sheetThickness * 0.5);
}
}
for (let y = 0; y < rows; y += 1) {
for (let x = 0; x < columns; x += 1) {
const a = y * (columns + 1) + x;
const b = a + 1;
const c = a + columns + 1;
const d = c + 1;
indices.push(a, c, b, b, c, d);
indices.push(bottomStart + a, bottomStart + b, bottomStart + c, bottomStart + b, bottomStart + d, bottomStart + c);
}
}
for (let y = 0; y < rows; y += 1) {
const topA = y * (columns + 1);
const topB = topA + columns + 1;
const bottomA = bottomStart + topA;
const bottomB = bottomStart + topB;
indices.push(topA, bottomA, topB, topB, bottomA, bottomB);
const outerA = y * (columns + 1) + columns;
const outerB = outerA + columns + 1;
const outerBottomA = bottomStart + outerA;
const outerBottomB = bottomStart + outerB;
indices.push(outerA, outerB, outerBottomA, outerB, outerBottomB, outerBottomA);
}
for (let x = 0; x < columns; x += 1) {
const headA = x;
const headB = x + 1;
const headBottomA = bottomStart + headA;
const headBottomB = bottomStart + headB;
indices.push(headA, headB, headBottomA, headB, headBottomB, headBottomA);
const tailA = rows * (columns + 1) + x;
const tailB = tailA + 1;
const tailBottomA = bottomStart + tailA;
const tailBottomB = bottomStart + tailB;
indices.push(tailA, tailBottomA, tailB, tailB, tailBottomA, tailBottomB);
}
const geometry = new THREE.BufferGeometry();
geometry.setIndex(indices);
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
geometry.computeVertexNormals();
return geometry;
}
function createProgressPageBlockGeometry(side, width, height, thickness, gutterW) {
const columns = 30;
const rows = 34;
const positions = [];
const uvs = [];
const indices = [];
const top = [];
const bottom = [];
const stackY = (u, v) => {
const gutterLift = 0.035 * Math.pow(1 - u, 1.7);
const edgeDrop = -0.018 * Math.pow(u, 1.25);
const pageCrown = 0.015 * Math.sin(Math.PI * u) * Math.sin(Math.PI * v);
return thickness * 0.5 + gutterLift + edgeDrop + pageCrown;
};
const push = (x, y, z, u, v) => {
const index = positions.length / 3;
positions.push(x, y, z);
uvs.push(u, v);
return index;
};
for (let y = 0; y <= rows; y += 1) {
const v = y / rows;
top[y] = [];
bottom[y] = [];
for (let x = 0; x <= columns; x += 1) {
const u = x / columns;
const px = side * (gutterW + u * width);
const pz = (v - 0.5) * height;
top[y][x] = push(px, progressPageTopY(side, thickness, u, v), pz, u, 1 - v);
bottom[y][x] = push(px, 0, pz, u, 1 - v);
}
}
for (let y = 0; y < rows; y += 1) {
for (let x = 0; x < columns; x += 1) {
indices.push(top[y][x], top[y + 1][x], top[y][x + 1], top[y][x + 1], top[y + 1][x], top[y + 1][x + 1]);
indices.push(bottom[y][x], bottom[y][x + 1], bottom[y + 1][x], bottom[y][x + 1], bottom[y + 1][x + 1], bottom[y + 1][x]);
}
}
for (let y = 0; y < rows; y += 1) {
indices.push(top[y][0], bottom[y][0], top[y + 1][0], top[y + 1][0], bottom[y][0], bottom[y + 1][0]);
indices.push(top[y][columns], top[y + 1][columns], bottom[y][columns], top[y + 1][columns], bottom[y + 1][columns], bottom[y][columns]);
}
for (let x = 0; x < columns; x += 1) {
indices.push(top[0][x], top[0][x + 1], bottom[0][x], top[0][x + 1], bottom[0][x + 1], bottom[0][x]);
indices.push(top[rows][x], bottom[rows][x], top[rows][x + 1], top[rows][x + 1], bottom[rows][x], bottom[rows][x + 1]);
}
const geometry = new THREE.BufferGeometry();
geometry.setIndex(indices);
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
geometry.computeVertexNormals();
return geometry;
}
function createPageEdgeSurfaceGeometry(side, width, height, thickness, gutterW) {
const rows = 44;
const layers = 7;
const positions = [];
const uvs = [];
const indices = [];
for (let y = 0; y <= layers; y += 1) {
const layer = y / layers;
for (let z = 0; z <= rows; z += 1) {
const v = z / rows;
const waviness = 0.012 * Math.sin(v * Math.PI * 5.0 + layer * 3.2);
const x = side * (gutterW + width + waviness);
const topY = progressPageTopY(side, thickness, 1, v);
positions.push(x, topY * layer, (v - 0.5) * height);
uvs.push(v, layer);
}
}
for (let y = 0; y < layers; y += 1) {
for (let z = 0; z < rows; z += 1) {
const a = y * (rows + 1) + z;
const b = a + 1;
const c = a + rows + 1;
const d = c + 1;
indices.push(a, c, b, b, c, d);
}
}
const geometry = new THREE.BufferGeometry();
geometry.setIndex(indices);
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
geometry.computeVertexNormals();
return geometry;
}
function createSpineGeometry(width, height, depth) {
const geometry = new THREE.CapsuleGeometry(width * 0.42, depth * 0.5, 5, 16);
geometry.rotateX(Math.PI / 2);
geometry.scale(1, height / width, 1);
return geometry;
}
function createGutterGeometry(width, height, depth) {
const geometry = new THREE.BoxGeometry(width, height, depth, 1, 1, 12);
const positions = geometry.attributes.position;
for (let i = 0; i < positions.count; i += 1) {
const x = positions.getX(i);
const z = positions.getZ(i);
const y = positions.getY(i);
const valley = -0.018 * (1 - Math.min(1, Math.abs(x) / (width * 0.5))) * (0.35 + 0.65 * Math.sin((z / depth + 0.5) * Math.PI));
positions.setY(i, y + valley);
}
positions.needsUpdate = true;
geometry.computeVertexNormals();
return geometry;
}
function createPageCanvas(side) {
const canvas = document.createElement('canvas');
canvas.width = pageTextureWidth;
canvas.height = Math.round(pageTextureWidth * PROCEDURAL_BOOK.PAGE_DEPTH / PROCEDURAL_BOOK.PAGE_WIDTH);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fffaf0';
ctx.fillRect(0, 0, canvas.width, canvas.height);
canvas.style.width = `${canvas.width}px`;
canvas.style.height = `${canvas.height}px`;
const shade = ctx.createLinearGradient(0, 0, canvas.width, 0);
shade.addColorStop(0, 'rgba(93, 55, 24, 0.10)');
shade.addColorStop(side === 'left' ? 0.85 : 0.15, 'rgba(255, 255, 255, 0)');
shade.addColorStop(1, 'rgba(85, 49, 21, 0.08)');
ctx.fillStyle = shade;
ctx.fillRect(0, 0, canvas.width, canvas.height);
return canvas;
}
function createLeatherTextures() {
const size = 1024;
const colorCanvas = document.createElement('canvas');
const normalCanvas = document.createElement('canvas');
const roughnessCanvas = document.createElement('canvas');
colorCanvas.width = size;
colorCanvas.height = size;
normalCanvas.width = size;
normalCanvas.height = size;
roughnessCanvas.width = size;
roughnessCanvas.height = size;
const colorContext = colorCanvas.getContext('2d');
const normalContext = normalCanvas.getContext('2d');
const roughnessContext = roughnessCanvas.getContext('2d');
const colorImage = colorContext.createImageData(size, size);
const normalImage = normalContext.createImageData(size, size);
const roughnessImage = roughnessContext.createImageData(size, size);
const heightAt = (x, y) => {
const nx = x / size;
const ny = y / size;
const longGrain = Math.sin((nx * 24 + Math.sin(ny * 31.4159265359) * 0.18) * 6.28318530718);
const secondaryGrain = Math.sin((nx * 63 + ny * 9 + Math.sin(ny * 50.2654824574) * 0.1) * 6.28318530718);
const crossGrain = Math.sin((ny * 39 + Math.sin(nx * 18.8495559215) * 0.12) * 6.28318530718);
const poreA = Math.sin((nx * 137 + ny * 71) * 6.28318530718);
const poreB = Math.sin((nx * 97 - ny * 113) * 6.28318530718);
const pebble = Math.sin((nx * 181 + Math.sin(ny * 25.1327412287) * 0.22) * 6.28318530718) *
Math.sin((ny * 167 + Math.sin(nx * 37.6991118431) * 0.18) * 6.28318530718);
const pit = Math.max(0, 0.58 - Math.abs(poreA * poreB));
return longGrain * 0.22 + secondaryGrain * 0.16 + crossGrain * 0.1 + pebble * 0.18 - pit * 0.24;
};
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) {
const wrappedX = (x + size) % size;
const wrappedY = (y + size) % size;
const height = heightAt(wrappedX, wrappedY);
const grain = THREE.MathUtils.clamp(0.58 + height * 0.24, 0, 1);
const warm = 0.86 + 0.1 * Math.sin((x * 0.045 + y * 0.011)) + 0.04 * Math.sin((x * 0.009 - y * 0.031));
const index = (y * size + x) * 4;
colorImage.data[index] = Math.round(118 * grain * warm);
colorImage.data[index + 1] = Math.round(54 * grain * warm);
colorImage.data[index + 2] = Math.round(22 * grain);
colorImage.data[index + 3] = 255;
const hLeft = heightAt((x - 1 + size) % size, wrappedY);
const hRight = heightAt((x + 1) % size, wrappedY);
const hDown = heightAt(wrappedX, (y - 1 + size) % size);
const hUp = heightAt(wrappedX, (y + 1) % size);
const normal = new THREE.Vector3((hLeft - hRight) * 4.1, (hDown - hUp) * 4.1, 1).normalize();
normalImage.data[index] = Math.round((normal.x * 0.5 + 0.5) * 255);
normalImage.data[index + 1] = Math.round((normal.y * 0.5 + 0.5) * 255);
normalImage.data[index + 2] = Math.round((normal.z * 0.5 + 0.5) * 255);
normalImage.data[index + 3] = 255;
const fiberContrast = Math.abs(hLeft - hRight) + Math.abs(hDown - hUp);
const roughness = THREE.MathUtils.clamp(0.76 + height * 0.1 + fiberContrast * 1.4, 0.5, 0.96);
const roughnessByte = Math.round(roughness * 255);
roughnessImage.data[index] = roughnessByte;
roughnessImage.data[index + 1] = roughnessByte;
roughnessImage.data[index + 2] = roughnessByte;
roughnessImage.data[index + 3] = 255;
}
}
colorContext.putImageData(colorImage, 0, 0);
normalContext.putImageData(normalImage, 0, 0);
roughnessContext.putImageData(roughnessImage, 0, 0);
const colorTexture = new THREE.CanvasTexture(colorCanvas);
const normalTexture = new THREE.CanvasTexture(normalCanvas);
const roughnessTexture = new THREE.CanvasTexture(roughnessCanvas);
[colorTexture, normalTexture, roughnessTexture].forEach((texture) => {
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set(3.6, 2.2);
texture.anisotropy = maxTextureAnisotropy;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = true;
});
colorTexture.colorSpace = THREE.SRGBColorSpace;
normalTexture.colorSpace = THREE.NoColorSpace;
roughnessTexture.colorSpace = THREE.NoColorSpace;
return { color: colorTexture, normal: normalTexture, roughness: roughnessTexture };
}
function createSpineClothTextures() {
const size = 1024;
const colorCanvas = document.createElement('canvas');
const normalCanvas = document.createElement('canvas');
const roughnessCanvas = document.createElement('canvas');
colorCanvas.width = size;
colorCanvas.height = size;
normalCanvas.width = size;
normalCanvas.height = size;
roughnessCanvas.width = size;
roughnessCanvas.height = size;
const colorContext = colorCanvas.getContext('2d');
const normalContext = normalCanvas.getContext('2d');
const roughnessContext = roughnessCanvas.getContext('2d');
const colorImage = colorContext.createImageData(size, size);
const normalImage = normalContext.createImageData(size, size);
const roughnessImage = roughnessContext.createImageData(size, size);
const threadAt = (x, y) => {
const nx = x / size;
const ny = y / size;
const warpPhase = nx * 112 + Math.sin(ny * 31.4159265359) * 0.025;
const weftPhase = ny * 76 + Math.sin(nx * 25.1327412287) * 0.02;
const warp = Math.pow(1 - Math.abs((warpPhase - Math.floor(warpPhase)) - 0.5) * 2, 2.2);
const weft = Math.pow(1 - Math.abs((weftPhase - Math.floor(weftPhase)) - 0.5) * 2, 2.0);
const fiber = Math.sin((nx * 430 + ny * 73) * 6.28318530718) * Math.sin((ny * 390 - nx * 41) * 6.28318530718);
const nap = Math.sin((nx * 19 + ny * 7) * 6.28318530718);
return warp * 0.46 + weft * 0.38 + fiber * 0.045 + nap * 0.055;
};
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) {
const index = (y * size + x) * 4;
const height = threadAt(x, y);
const wornFiber = 0.86 + 0.1 * Math.sin((x * 0.019 + y * 0.037)) + 0.04 * Math.sin((x * 0.083 - y * 0.011));
const threadGlow = THREE.MathUtils.clamp(0.58 + height * 0.46, 0, 1);
colorImage.data[index] = Math.round(128 * threadGlow * wornFiber);
colorImage.data[index + 1] = Math.round(22 * threadGlow * wornFiber);
colorImage.data[index + 2] = Math.round(18 * (0.86 + height * 0.12));
colorImage.data[index + 3] = 255;
const hLeft = threadAt((x - 1 + size) % size, y);
const hRight = threadAt((x + 1) % size, y);
const hDown = threadAt(x, (y - 1 + size) % size);
const hUp = threadAt(x, (y + 1) % size);
const normal = new THREE.Vector3((hLeft - hRight) * 5.4, (hDown - hUp) * 5.4, 1).normalize();
normalImage.data[index] = Math.round((normal.x * 0.5 + 0.5) * 255);
normalImage.data[index + 1] = Math.round((normal.y * 0.5 + 0.5) * 255);
normalImage.data[index + 2] = Math.round((normal.z * 0.5 + 0.5) * 255);
normalImage.data[index + 3] = 255;
const fiberContrast = Math.abs(hLeft - hRight) + Math.abs(hDown - hUp);
const roughness = THREE.MathUtils.clamp(0.84 + height * 0.07 + fiberContrast * 1.25, 0.62, 0.98);
const roughnessByte = Math.round(roughness * 255);
roughnessImage.data[index] = roughnessByte;
roughnessImage.data[index + 1] = roughnessByte;
roughnessImage.data[index + 2] = roughnessByte;
roughnessImage.data[index + 3] = 255;
}
}
colorContext.putImageData(colorImage, 0, 0);
normalContext.putImageData(normalImage, 0, 0);
roughnessContext.putImageData(roughnessImage, 0, 0);
const colorTexture = new THREE.CanvasTexture(colorCanvas);
const normalTexture = new THREE.CanvasTexture(normalCanvas);
const roughnessTexture = new THREE.CanvasTexture(roughnessCanvas);
[colorTexture, normalTexture, roughnessTexture].forEach((texture) => {
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set(2.1, 4.4);
texture.anisotropy = maxTextureAnisotropy;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = true;
});
colorTexture.colorSpace = THREE.SRGBColorSpace;
normalTexture.colorSpace = THREE.NoColorSpace;
roughnessTexture.colorSpace = THREE.NoColorSpace;
return { color: colorTexture, normal: normalTexture, roughness: roughnessTexture };
}
function createHeadbandTextures() {
const width = 1024;
const height = 256;
const colorCanvas = document.createElement('canvas');
const normalCanvas = document.createElement('canvas');
const roughnessCanvas = document.createElement('canvas');
colorCanvas.width = width;
colorCanvas.height = height;
normalCanvas.width = width;
normalCanvas.height = height;
roughnessCanvas.width = width;
roughnessCanvas.height = height;
const colorContext = colorCanvas.getContext('2d');
const normalContext = normalCanvas.getContext('2d');
const roughnessContext = roughnessCanvas.getContext('2d');
const colorImage = colorContext.createImageData(width, height);
const normalImage = normalContext.createImageData(width, height);
const roughnessImage = roughnessContext.createImageData(width, height);
const threadAt = (x, y) => {
const u = x / width;
const v = y / height;
const wrap = u * 44 + v * 7.5;
const phase = wrap - Math.floor(wrap);
const rib = Math.pow(1 - Math.abs(phase - 0.5) * 2, 0.55);
const warp = Math.pow(1 - Math.abs(((u * 190 + v * 9) % 1) - 0.5) * 2, 1.1);
const weft = Math.pow(1 - Math.abs(((v * 38 + u * 4.5) % 1) - 0.5) * 2, 1.25);
return rib * 0.72 + warp * 0.16 + weft * 0.12;
};
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const index = (y * width + x) * 4;
const u = x / width;
const v = y / height;
const wrap = u * 44 + v * 7.5;
const alternate = Math.floor(wrap) % 2;
const heightValue = threadAt(x, y);
const cotton = Math.sin((u * 410 + v * 79) * 6.28318530718) * 0.025;
const shade = THREE.MathUtils.clamp(0.76 + heightValue * 0.18 + cotton, 0.58, 1.0);
const red = [166, 30, 24];
const ivory = [218, 190, 136];
const linen = [152, 116, 82];
const base = alternate === 0 ? red : ivory;
const blend = THREE.MathUtils.clamp(heightValue * 1.08, 0, 1);
colorImage.data[index] = Math.round(THREE.MathUtils.lerp(linen[0], base[0], blend) * shade);
colorImage.data[index + 1] = Math.round(THREE.MathUtils.lerp(linen[1], base[1], blend) * shade);
colorImage.data[index + 2] = Math.round(THREE.MathUtils.lerp(linen[2], base[2], blend) * shade);
colorImage.data[index + 3] = 255;
const hLeft = threadAt((x - 1 + width) % width, y);
const hRight = threadAt((x + 1) % width, y);
const hDown = threadAt(x, (y - 1 + height) % height);
const hUp = threadAt(x, (y + 1) % height);
const normal = new THREE.Vector3((hLeft - hRight) * 3.8, (hDown - hUp) * 3.8, 1).normalize();
normalImage.data[index] = Math.round((normal.x * 0.5 + 0.5) * 255);
normalImage.data[index + 1] = Math.round((normal.y * 0.5 + 0.5) * 255);
normalImage.data[index + 2] = Math.round((normal.z * 0.5 + 0.5) * 255);
normalImage.data[index + 3] = 255;
const roughness = THREE.MathUtils.clamp(0.74 + heightValue * 0.16 + Math.abs(hLeft - hRight) * 0.8, 0.58, 0.96);
const roughnessByte = Math.round(roughness * 255);
roughnessImage.data[index] = roughnessByte;
roughnessImage.data[index + 1] = roughnessByte;
roughnessImage.data[index + 2] = roughnessByte;
roughnessImage.data[index + 3] = 255;
}
}
colorContext.putImageData(colorImage, 0, 0);
normalContext.putImageData(normalImage, 0, 0);
roughnessContext.putImageData(roughnessImage, 0, 0);
const colorTexture = new THREE.CanvasTexture(colorCanvas);
const normalTexture = new THREE.CanvasTexture(normalCanvas);
const roughnessTexture = new THREE.CanvasTexture(roughnessCanvas);
[colorTexture, normalTexture, roughnessTexture].forEach((texture) => {
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set(1.0, 1.0);
texture.anisotropy = maxTextureAnisotropy;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = true;
});
colorTexture.colorSpace = THREE.SRGBColorSpace;
normalTexture.colorSpace = THREE.NoColorSpace;
roughnessTexture.colorSpace = THREE.NoColorSpace;
return { color: colorTexture, normal: normalTexture, roughness: roughnessTexture };
}
function createHardcoverPaperTextures() {
const size = 1024;
const colorCanvas = document.createElement('canvas');
const edgeCanvas = document.createElement('canvas');
const normalCanvas = document.createElement('canvas');
const roughnessCanvas = document.createElement('canvas');
[colorCanvas, edgeCanvas, normalCanvas, roughnessCanvas].forEach((canvas) => {
canvas.width = size;
canvas.height = size;
});
const colorContext = colorCanvas.getContext('2d');
const edgeContext = edgeCanvas.getContext('2d');
const normalContext = normalCanvas.getContext('2d');
const roughnessContext = roughnessCanvas.getContext('2d');
const colorImage = colorContext.createImageData(size, size);
const edgeImage = edgeContext.createImageData(size, size);
const normalImage = normalContext.createImageData(size, size);
const roughnessImage = roughnessContext.createImageData(size, size);
const fiberAt = (x, y) => {
const nx = x / size;
const ny = y / size;
const pulpA = Math.sin((nx * 173 + ny * 67) * 6.28318530718);
const pulpB = Math.sin((nx * 89 - ny * 131) * 6.28318530718);
const cloudA = Math.sin((nx * 19 + ny * 11) * 6.28318530718);
const cloudB = Math.sin((nx * 31 - ny * 27) * 6.28318530718);
const fleck = Math.max(0, 0.5 - Math.abs(pulpA * pulpB));
return cloudA * cloudB * 0.026 - fleck * 0.035;
};
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) {
const index = (y * size + x) * 4;
const fiber = fiberAt(x, y);
const warmth = 0.97 + 0.018 * Math.sin(x * 0.017 + y * 0.003) + 0.012 * Math.sin(y * 0.041);
const shade = THREE.MathUtils.clamp(0.975 + fiber, 0.88, 1.0);
colorImage.data[index] = Math.round(255 * shade * warmth);
colorImage.data[index + 1] = Math.round(251 * shade * warmth);
colorImage.data[index + 2] = Math.round(235 * shade);
colorImage.data[index + 3] = 255;
const linePhase = (y + Math.sin(x * 0.021) * 4) % 34;
const line = linePhase < 1.2 ? 0.72 : linePhase < 2.1 ? 0.82 : 1;
edgeImage.data[index] = Math.round(255 * shade * line);
edgeImage.data[index + 1] = Math.round(244 * shade * line);
edgeImage.data[index + 2] = Math.round(207 * shade * line);
edgeImage.data[index + 3] = 255;
const hLeft = fiberAt((x - 1 + size) % size, y);
const hRight = fiberAt((x + 1) % size, y);
const hDown = fiberAt(x, (y - 1 + size) % size);
const hUp = fiberAt(x, (y + 1) % size);
const normal = new THREE.Vector3((hLeft - hRight) * 3.2, (hDown - hUp) * 3.2, 1).normalize();
normalImage.data[index] = Math.round((normal.x * 0.5 + 0.5) * 255);
normalImage.data[index + 1] = Math.round((normal.y * 0.5 + 0.5) * 255);
normalImage.data[index + 2] = Math.round((normal.z * 0.5 + 0.5) * 255);
normalImage.data[index + 3] = 255;
const roughness = THREE.MathUtils.clamp(0.86 + Math.abs(fiber) * 0.5 + Math.abs(hLeft - hRight + hDown - hUp) * 1.2, 0.72, 0.98);
const roughnessByte = Math.round(roughness * 255);
roughnessImage.data[index] = roughnessByte;
roughnessImage.data[index + 1] = roughnessByte;
roughnessImage.data[index + 2] = roughnessByte;
roughnessImage.data[index + 3] = 255;
}
}
colorContext.putImageData(colorImage, 0, 0);
edgeContext.putImageData(edgeImage, 0, 0);
normalContext.putImageData(normalImage, 0, 0);
roughnessContext.putImageData(roughnessImage, 0, 0);
const colorTexture = new THREE.CanvasTexture(colorCanvas);
const edgeTexture = new THREE.CanvasTexture(edgeCanvas);
const normalTexture = new THREE.CanvasTexture(normalCanvas);
const roughnessTexture = new THREE.CanvasTexture(roughnessCanvas);
[colorTexture, edgeTexture, normalTexture, roughnessTexture].forEach((texture) => {
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set(2.6, 3.4);
texture.anisotropy = maxTextureAnisotropy;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = true;
});
colorTexture.colorSpace = THREE.SRGBColorSpace;
edgeTexture.colorSpace = THREE.SRGBColorSpace;
normalTexture.colorSpace = THREE.NoColorSpace;
roughnessTexture.colorSpace = THREE.NoColorSpace;
return { color: colorTexture, edge: edgeTexture, normal: normalTexture, roughness: roughnessTexture };
}
function createRoomReflectionTexture() {
const canvas = document.createElement('canvas');
generatedTextureCanvases.roomReflection = canvas;
canvas.width = 2048;
canvas.height = 1024;
const ctx = canvas.getContext('2d');
const wall = ctx.createLinearGradient(0, 0, 0, canvas.height);
wall.addColorStop(0, '#050302');
wall.addColorStop(0.36, '#140906');
wall.addColorStop(0.72, '#2b150b');
wall.addColorStop(1, '#060302');
ctx.fillStyle = wall;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.globalAlpha = 0.28;
for (let i = 0; i < 7; i += 1) {
const x = 170 + i * 285;
const glow = ctx.createRadialGradient(x, 520, 0, x, 520, 300);
glow.addColorStop(0, 'rgba(255, 157, 64, 0.55)');
glow.addColorStop(0.2, 'rgba(144, 68, 27, 0.28)');
glow.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = glow;
ctx.fillRect(x - 330, 190, 660, 660);
}
ctx.globalAlpha = 0.16;
ctx.fillStyle = '#c99655';
for (let i = 0; i < 10; i += 1) {
ctx.fillRect(120 + i * 190, 230, 52, 390);
}
ctx.globalAlpha = 1;
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.mapping = THREE.EquirectangularReflectionMapping;
texture.needsUpdate = true;
return texture;
}
function loadAiRoomReflection() {
new THREE.TextureLoader().load('/assets/webgl/room_reflection_candlelit_study_equirect_4k.png', (texture) => {
texture.colorSpace = THREE.SRGBColorSpace;
texture.mapping = THREE.EquirectangularReflectionMapping;
texture.anisotropy = renderer.capabilities.getMaxAnisotropy();
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = true;
texture.needsUpdate = true;
tableRoomReflectionTexture = texture;
if (tableShader) {
tableShader.uniforms.roomReflectionMap.value = texture;
}
const image = texture.image;
if (!image) return;
const canvas = document.createElement('canvas');
canvas.width = image.naturalWidth || image.width;
canvas.height = image.naturalHeight || image.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
generatedTextureCanvases.aiRoomReflection = canvas;
tintAmbientFromCanvas(canvas);
}, undefined, () => {
tintAmbientFromCanvas(generatedTextureCanvases.roomReflection);
});
}
function tintAmbientFromCanvas(canvas) {
if (!canvas || !candleBounceLight) return;
const ctx = canvas.getContext('2d');
const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
let r = 0;
let g = 0;
let b = 0;
let count = 0;
for (let i = 0; i < data.length; i += 256) {
r += data[i];
g += data[i + 1];
b += data[i + 2];
count += 1;
}
const color = new THREE.Color(r / count / 255, g / count / 255, b / count / 255);
color.offsetHSL(0, 0.08, 0.04);
candleBounceLight.color.copy(color);
candleBounceLight.intensity = 0.28;
}
function resize() {
const width = Math.max(1, window.innerWidth);
const height = Math.max(1, window.innerHeight);
renderer.setSize(width, height, false);
if (composer) composer.setSize(width, height);
if (sceneAoPass) sceneAoPass.setSize(width, height);
camera.aspect = width / height;
camera.updateProjectionMatrix();
const desiredReflectionScale = reflectionPixelRatio * 1.5;
const reflectionScale = Math.max(1, Math.min(
desiredReflectionScale,
4096 / width,
2304 / height
));
const reflectionWidth = Math.min(tableReflectionBaseWidth, Math.floor(width * reflectionScale));
const reflectionHeight = Math.min(tableReflectionBaseHeight, Math.floor(height * reflectionScale));
reflectionTargetSize.set(reflectionWidth, reflectionHeight);
tableReflectionTarget.setSize(
reflectionTargetSize.x,
reflectionTargetSize.y
);
}
function installCameraControls() {
canvas.addEventListener('contextmenu', (event) => {
event.preventDefault();
});
canvas.addEventListener('pointerdown', (event) => {
if (event.button !== 2) return;
cameraRig.dragging = true;
cameraRig.navigationActive = true;
canvas.style.cursor = 'grabbing';
cameraRig.pointerX = event.clientX;
cameraRig.pointerY = event.clientY;
canvas.setPointerCapture(event.pointerId);
});
canvas.addEventListener('pointermove', (event) => {
if (!cameraRig.dragging) return;
const dx = event.clientX - cameraRig.pointerX;
const dy = event.clientY - cameraRig.pointerY;
cameraRig.pointerX = event.clientX;
cameraRig.pointerY = event.clientY;
cameraRig.yaw -= dx * 0.006;
cameraRig.pitch = THREE.MathUtils.clamp(
cameraRig.pitch + dy * 0.004,
cameraRig.minPitch,
cameraRig.maxPitch
);
updateCameraRig(0);
});
canvas.addEventListener('pointerup', (event) => {
if (event.button !== 2) return;
cameraRig.dragging = false;
cameraRig.navigationActive = false;
cameraRig.keys.clear();
canvas.style.cursor = 'grab';
canvas.releasePointerCapture(event.pointerId);
});
canvas.addEventListener('pointercancel', () => {
cameraRig.dragging = false;
cameraRig.navigationActive = false;
cameraRig.keys.clear();
canvas.style.cursor = 'grab';
});
canvas.addEventListener('wheel', (event) => {
if (!cameraRig.navigationActive) return;
event.preventDefault();
const zoom = Math.exp(event.deltaY * 0.001);
cameraRig.radius = THREE.MathUtils.clamp(
cameraRig.radius * zoom,
cameraRig.minRadius,
cameraRig.maxRadius
);
updateCameraRig(0);
}, { passive: false });
window.addEventListener('keydown', (event) => {
if (!cameraRig.navigationActive) return;
if (['KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(event.code)) {
cameraRig.keys.add(event.code);
event.preventDefault();
}
});
window.addEventListener('keyup', (event) => {
cameraRig.keys.delete(event.code);
});
}
function updateCameraRig(deltaSeconds) {
if (deltaSeconds > 0 && cameraRig.keys.size) {
const forward = new THREE.Vector3();
camera.getWorldDirection(forward);
forward.y = 0;
forward.normalize();
const right = new THREE.Vector3().crossVectors(forward, camera.up).normalize();
const move = new THREE.Vector3();
if (cameraRig.keys.has('KeyW')) move.add(forward);
if (cameraRig.keys.has('KeyS')) move.sub(forward);
if (cameraRig.keys.has('KeyD')) move.add(right);
if (cameraRig.keys.has('KeyA')) move.sub(right);
if (move.lengthSq() > 0) {
move.normalize().multiplyScalar(deltaSeconds * cameraRig.radius * 0.72);
cameraRig.target.add(move);
cameraRig.target.x = THREE.MathUtils.clamp(cameraRig.target.x, -2.6, 2.6);
cameraRig.target.z = THREE.MathUtils.clamp(cameraRig.target.z, -1.9, 1.9);
}
}
const horizontalRadius = Math.sin(cameraRig.pitch) * cameraRig.radius;
camera.position.set(
cameraRig.target.x + Math.sin(cameraRig.yaw) * horizontalRadius,
cameraRig.target.y + Math.cos(cameraRig.pitch) * cameraRig.radius,
cameraRig.target.z + Math.cos(cameraRig.yaw) * horizontalRadius
);
camera.lookAt(cameraRig.target);
}
function updateCandleShadowUniforms() {
if (!tableShader) return;
candleShadowSources.forEach((candle, index) => {
if (index >= 3) return;
candle.getWorldPosition(candleWorldPosition);
candle.userData.flame.getWorldPosition(flameWorldPosition);
tableShader.uniforms.candleBodyPositions.value[index].set(
candleWorldPosition.x,
candleWorldPosition.y - 0.05,
candleWorldPosition.z
);
tableShader.uniforms.candleFlamePositions.value[index].copy(flameWorldPosition);
tableShader.uniforms.candleBodyData.value[index].set(
candle.userData.bodyRadius,
candle.userData.bodyHeight
);
});
}
function updateBookShadowMaps() {
if (!tableShader || candleShadowSources.length < 3) return;
const previousRenderTarget = renderer.getRenderTarget();
const previousXrEnabled = renderer.xr.enabled;
const previousShadowAutoUpdate = renderer.shadowMap.autoUpdate;
const previousToneMappingExposure = renderer.toneMappingExposure;
const previousOverrideMaterial = scene.overrideMaterial;
const previousClearColor = new THREE.Color();
renderer.getClearColor(previousClearColor);
const previousClearAlpha = renderer.getClearAlpha();
const hiddenObjects = [tableMesh, ...candleShadowSources].filter(Boolean);
hiddenObjects.forEach((object) => {
object.userData.wasVisibleForBookShadow = object.visible;
object.visible = false;
});
renderer.xr.enabled = false;
renderer.shadowMap.autoUpdate = false;
renderer.toneMappingExposure = 1;
renderer.setClearColor(0xffffff, 1);
scene.overrideMaterial = bookShadowDepthMaterial;
candleShadowSources.forEach((candle, index) => {
candle.userData.flame.getWorldPosition(flameWorldPosition);
const shadowCamera = bookShadowCameras[index];
shadowCamera.position.copy(flameWorldPosition);
shadowCamera.lookAt(0, 0.09, 0);
shadowCamera.updateProjectionMatrix();
shadowCamera.updateMatrixWorld();
shadowCamera.matrixWorldInverse.copy(shadowCamera.matrixWorld).invert();
bookShadowMatrices[index]
.copy(bookShadowBiasMatrix)
.multiply(shadowCamera.projectionMatrix)
.multiply(shadowCamera.matrixWorldInverse);
renderer.setRenderTarget(bookShadowTargets[index]);
renderer.clear();
renderer.render(scene, shadowCamera);
});
scene.overrideMaterial = previousOverrideMaterial;
hiddenObjects.forEach((object) => {
object.visible = object.userData.wasVisibleForBookShadow;
delete object.userData.wasVisibleForBookShadow;
});
renderer.setClearColor(previousClearColor, previousClearAlpha);
renderer.toneMappingExposure = previousToneMappingExposure;
renderer.shadowMap.autoUpdate = previousShadowAutoUpdate;
renderer.xr.enabled = previousXrEnabled;
renderer.setRenderTarget(previousRenderTarget);
}
function updateTableReflection() {
if (!tableMesh || !tableShader) return;
tableReflectionCamera.copy(camera);
tableReflectionCamera.position.set(
camera.position.x,
tableTopY - (camera.position.y - tableTopY),
camera.position.z
);
reflectionTarget.copy(cameraRig.target);
reflectionTarget.y = tableTopY - (reflectionTarget.y - tableTopY);
reflectionUp.setFromMatrixColumn(camera.matrixWorld, 1);
reflectionUp.y *= -1;
tableReflectionCamera.up.copy(reflectionUp);
tableReflectionCamera.lookAt(reflectionTarget);
tableReflectionCamera.updateProjectionMatrix();
tableReflectionCamera.updateMatrixWorld();
tableReflectionCamera.matrixWorldInverse.copy(tableReflectionCamera.matrixWorld).invert();
tableReflectionMatrix
.copy(tableReflectionBiasMatrix)
.multiply(tableReflectionCamera.projectionMatrix)
.multiply(tableReflectionCamera.matrixWorldInverse);
const previousRenderTarget = renderer.getRenderTarget();
const previousXrEnabled = renderer.xr.enabled;
const previousShadowAutoUpdate = renderer.shadowMap.autoUpdate;
const previousToneMappingExposure = renderer.toneMappingExposure;
const pageTextureState = suppressPageContentMaps();
tableMesh.userData.wasVisibleForTableReflection = tableMesh.visible;
tableMesh.visible = false;
renderer.xr.enabled = false;
renderer.shadowMap.autoUpdate = false;
renderer.toneMappingExposure = 0.92;
renderer.setRenderTarget(tableReflectionTarget);
renderer.clear();
renderer.render(scene, tableReflectionCamera);
renderer.setRenderTarget(previousRenderTarget);
renderer.toneMappingExposure = previousToneMappingExposure;
renderer.shadowMap.autoUpdate = previousShadowAutoUpdate;
renderer.xr.enabled = previousXrEnabled;
restorePageContentMaps(pageTextureState);
tableMesh.visible = tableMesh.userData.wasVisibleForTableReflection;
delete tableMesh.userData.wasVisibleForTableReflection;
}
function suppressPageContentMaps() {
if (!isAppIntegrationMode) return null;
return [materials.leftPage, materials.rightPage].map((material) => {
const previousMap = material.map;
material.map = null;
material.needsUpdate = true;
return { material, previousMap };
});
}
function restorePageContentMaps(state) {
if (!state) return;
state.forEach(({ material, previousMap }) => {
material.map = previousMap;
material.needsUpdate = true;
});
}
function renderMirrorDebugView() {
const hiddenObjects = [];
scene.traverse((object) => {
if (object === tableMesh || !object.visible) return;
if (!object.isMesh && !object.isLine && !object.isPoints && !object.isSprite) return;
object.userData.wasVisibleForMirrorDebug = true;
object.visible = false;
hiddenObjects.push(object);
});
renderer.render(scene, camera);
hiddenObjects.forEach((object) => {
object.visible = object.userData.wasVisibleForMirrorDebug;
delete object.userData.wasVisibleForMirrorDebug;
});
}
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
const t = clock.elapsedTime;
updateCameraRig(delta);
scene.traverse((object) => {
if (!object.userData?.light) return;
const swayX = Math.sin(t * 5.7 + object.userData.seed) * 0.012;
const swayZ = Math.cos(t * 4.9 + object.userData.seed * 0.7) * 0.01;
const pulse = 0.9 + Math.sin(t * 7.3 + object.userData.seed) * 0.09 + Math.sin(t * 13.1) * 0.045;
object.userData.light.intensity = object.userData.baseIntensity * pulse * (object.position.x < 0 ? 1.08 : 0.92);
object.userData.flame.scale.y = 1.65 + Math.sin(t * 9.2 + object.userData.seed) * 0.18;
object.userData.flame.position.x = swayX * 0.75;
object.userData.flame.position.z = swayZ * 0.75;
object.userData.flame.traverse((child) => {
if (child.material?.uniforms?.time) child.material.uniforms.time.value = t + object.userData.seed;
});
object.userData.light.position.copy(object.userData.flame.position);
object.userData.waxGlow.material.opacity = 0.07 + Math.max(0, pulse - 0.9) * 0.08;
const waxShader = object.userData.waxMaterial.userData.shader;
if (waxShader) {
object.getWorldPosition(candleWorldPosition);
object.userData.flame.getWorldPosition(flameWorldPosition);
waxShader.uniforms.waxFlameWorldPosition.value.copy(flameWorldPosition);
waxShader.uniforms.waxBodyWorldPosition.value.set(
candleWorldPosition.x,
candleWorldPosition.y - 0.05,
candleWorldPosition.z
);
waxShader.uniforms.waxLightPower.value = THREE.MathUtils.clamp(pulse * object.userData.baseIntensity * 0.42, 0.35, 1.6);
}
});
updateActiveFlips(performance.now());
updateCandleShadowUniforms();
renderedFrameCount += 1;
if (!isAppIntegrationMode || renderedFrameCount % 6 === 1 || activeFlips.length > 0) {
updateBookShadowMaps();
}
if (!isAppIntegrationMode || renderedFrameCount % 4 === 1 || cameraRig.navigationActive || activeFlips.length > 0) {
updateTableReflection();
}
if (tableDebugMode === tableDebugModes.mirror) {
renderer.setRenderTarget(null);
renderer.clear();
renderMirrorDebugView();
} else if (composer) {
composer.render();
} else {
renderer.render(scene, camera);
}
window.BookLabDebug.renderedFrames += 1;
window.BookLabDebug.ready = true;
}