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=20260608-webgl-mask-timing-c'; 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 appRenderPixelRatio = 2; 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(appRenderPixelRatio); 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 = 1; const pageTextureWidth = 3072; 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; let staticSceneBuffersDirty = true; let lastStaticCameraSignature = ''; let lastResizeWidth = 0; let lastResizeHeight = 0; function reportLabProgress(percent, message) { if (typeof appInitialState.reportProgress === 'function') { appInitialState.reportProgress(percent, message); } } async function reportLabStep(percent, message) { reportLabProgress(percent, message); await new Promise(resolve => requestAnimationFrame(resolve)); } 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 = 2048; const tableReflectionBaseHeight = 1152; const tableReflectionTarget = new THREE.WebGLRenderTarget(tableReflectionBaseWidth, tableReflectionBaseHeight, { colorSpace: THREE.SRGBColorSpace, depthBuffer: true, stencilBuffer: false, samples: renderer.capabilities.isWebGL2 ? 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 = 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 targetFrameDurationMs = 1000 / 60; let lastRenderFrameAt = 0; let fpsDisplay = null; let fpsWindowStartedAt = performance.now(); let fpsWindowFrames = 0; const lastFrameTiming = {}; const loaderTimings = {}; const pageTextureTimings = []; function markLoaderTiming(name) { loaderTimings[name] = performance.now(); document.documentElement.dataset.webglLoaderTimings = JSON.stringify(loaderTimings); } function markPageTextureTiming(name, detail = {}) { const entry = { name, at: performance.now(), detail }; pageTextureTimings.push(entry); if (pageTextureTimings.length > 120) pageTextureTimings.splice(0, pageTextureTimings.length - 120); document.documentElement.dataset.webglPageTextureTimings = JSON.stringify(pageTextureTimings); return entry; } const book = new THREE.Group(); scene.add(book); const initialReadingProgress = THREE.MathUtils.clamp(Number.parseFloat(urlParams.get('progress') ?? appInitialState.progress ?? '0'), 0, 1); let readingProgress = Number.isFinite(initialReadingProgress) ? initialReadingProgress : 0; let bookPageCount = snapProceduralPageCount(urlParams.get('pages') ?? appInitialState.pageCount ?? '300'); let pageReserve = clampPageReserve(appInitialState.pageReserve ?? 50, bookPageCount); 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'); let bottomNavigation = null; let bookPaginationState = { spreadIndex: 0, spreadCount: 1, writtenPageLimit: 0 }; const normalFlipDuration = 900; const fastFlipDuration = 520; const fastFlipCount = 10; const fastFlipOverlap = 5; let activeFlips = []; let pendingPageFlips = 0; const pendingRevealStartBlockIds = new Set(); const activeRevealBlockStarts = new Map(); const paperColor = new THREE.Color(0xece4ca); const inkColor = '#1a1009'; const maxRevealRegions = 128; const completedRevealElapsedMs = 1000000000; await reportLabStep(48, 'Preparing high-resolution page textures'); const leftCanvas = createPageCanvas('left'); const rightCanvas = createPageCanvas('right'); const leftTexture = new THREE.CanvasTexture(leftCanvas); const rightTexture = new THREE.CanvasTexture(rightCanvas); function configurePageCanvasTexture(texture) { texture.colorSpace = THREE.SRGBColorSpace; texture.anisotropy = maxTextureAnisotropy; texture.minFilter = THREE.LinearFilter; texture.magFilter = THREE.LinearFilter; texture.generateMipmaps = false; return texture; } [leftTexture, rightTexture].forEach(configurePageCanvasTexture); function createPageCanvasTexture(sourceCanvas) { if (!sourceCanvas) return null; const texture = configurePageCanvasTexture(new THREE.CanvasTexture(sourceCanvas)); texture.needsUpdate = true; if (typeof renderer?.initTexture === 'function') { renderer.initTexture(texture); texture.needsUpdate = false; } return texture; } function getBlankPageTexture() { if (blankPageTexture) return blankPageTexture; blankPageTexture = createPageCanvasTexture(createPageCanvas('blank')); return blankPageTexture; } const preparedPageTextures = { left: new Map(), right: new Map() }; const residentPageTextures = new Map(); const maxResidentPageTextures = 192; let blankPageTexture = null; const pageCacheProblemLog = []; let currentPageMeta = { left: null, right: null }; let pendingRightPageFlip = false; let pendingRightPageFlipAutoplay = false; const pageRevealState = { left: null, right: null }; const pageRevealClearLog = []; await reportLabStep(52, 'Generating leather texture set'); const leatherTextures = createLeatherTextures(); await reportLabStep(56, 'Generating spine cloth texture set'); const spineClothTextures = createSpineClothTextures(); await reportLabStep(60, 'Generating headband texture set'); const headbandTextures = createHeadbandTextures(); await reportLabStep(64, 'Generating paper texture set'); const paperTextures = createHardcoverPaperTextures(); await reportLabStep(68, 'Creating WebGL book materials'); 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: 0xeee6cc, map: paperTextures.color, normalMap: paperTextures.normal, normalScale: new THREE.Vector2(0.008, 0.008), roughnessMap: paperTextures.roughness, roughness: 0.88, metalness: 0, envMapIntensity: 0.025 }), pageEdge: new THREE.MeshStandardMaterial({ color: 0xe8ddbe, map: paperTextures.edge, normalMap: paperTextures.normal, normalScale: new THREE.Vector2(0.008, 0.008), roughnessMap: paperTextures.roughness, roughness: 0.94, metalness: 0, envMapIntensity: 0.02 }), pageSurface: new THREE.MeshStandardMaterial({ color: 0xeee6cc, map: paperTextures.color, normalMap: paperTextures.normal, normalScale: new THREE.Vector2(0.006, 0.006), roughnessMap: paperTextures.roughness, roughness: 0.9, metalness: 0, emissive: 0x14110b, emissiveIntensity: 0.004, envMapIntensity: 0.012, side: THREE.DoubleSide }), flipPageSurface: new THREE.MeshStandardMaterial({ color: 0xeee6cc, roughness: 0.92, metalness: 0, emissive: 0x100d08, emissiveIntensity: 0.004, envMapIntensity: 0.01, side: THREE.DoubleSide }), leftPage: new THREE.MeshStandardMaterial({ color: 0xffffff, map: leftTexture, normalMap: paperTextures.normal, normalScale: new THREE.Vector2(0.004, 0.004), roughnessMap: paperTextures.roughness, roughness: 0.86, metalness: 0, emissive: 0x11100c, emissiveIntensity: 0.004, side: THREE.DoubleSide }), rightPage: new THREE.MeshStandardMaterial({ color: 0xffffff, map: rightTexture, normalMap: paperTextures.normal, normalScale: new THREE.Vector2(0.004, 0.004), roughnessMap: paperTextures.roughness, roughness: 0.86, metalness: 0, emissive: 0x11100c, emissiveIntensity: 0.004, 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.flipPageBackSurface = materials.flipPageSurface.clone(); materials.flipPageBackSurface.map = getBlankPageTexture(); materials.flipPageBackSurface.side = THREE.DoubleSide; materials.flipPageEdge = materials.pageSurface.clone(); materials.flipPageEdge.map = paperTextures.edge; materials.flipPageEdge.normalMap = paperTextures.normal; materials.flipPageEdge.roughnessMap = paperTextures.roughness; materials.flipPageEdge.side = THREE.DoubleSide; materials.leftPage.userData.bookPageReveal = { side: 'left' }; materials.rightPage.userData.bookPageReveal = { side: 'right' }; 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.18); configureBookShadowReceiver(materials.pageEdge, 0.16); configureBookShadowReceiver(materials.pageSurface, 0.11); configureBookShadowReceiver(materials.flipPageSurface, 0.11); configureBookShadowReceiver(materials.flipPageBackSurface, 0.11); configureBookShadowReceiver(materials.flipPageEdge, 0.09); configureBookShadowReceiver(materials.leftPage, 0.08); configureBookShadowReceiver(materials.rightPage, 0.08); configureBookShadowReceiver(materials.spineCloth, 0.48); configureBookShadowReceiver(materials.headband, 0.62); await reportLabStep(70, 'Building reflective table'); buildTable(); await reportLabStep(74, 'Building candle lighting'); buildLighting(); await reportLabStep(78, 'Building physical book stack'); buildBook(); notifyBookPageCountChanged(); await reportLabStep(82, 'Loading room reflection texture'); await loadAiRoomReflection(); await reportLabStep(86, 'Preparing static shadow and mirror maps'); resize(); primeSceneForLoader(); await reportLabStep(90, 'Compiled WebGL scene passes'); window.BookLabDebug = { cacheKey: window.MODULE_CACHE_BUSTER || null, textures: generatedTextureCanvases, ready: false, renderedFrames: 0, loaderTimings, pageTextureTimings, pageRevealClearLog, 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; }, setPageReserve(value) { setPageReserve(value); return pageReserve; }, getBookState() { return { pageCount: bookPageCount, pageReserve, progress: readingProgress, pagePosition: getCurrentPagePosition(), spreadIndex: bookPaginationState.spreadIndex, writtenPageLimit: bookPaginationState.writtenPageLimit }; }, setPaginationStateForTest(state = {}) { bookPaginationState = { spreadIndex: Math.max(0, Number(state.spreadIndex ?? bookPaginationState.spreadIndex ?? 0)), spreadCount: Math.max(1, Number(state.spreadCount ?? bookPaginationState.spreadCount ?? 1)), writtenPageLimit: Math.max(0, Number(state.writtenPageLimit ?? bookPaginationState.writtenPageLimit ?? 0)) }; growBookIfWritableLimitReached(); syncBookControls(); return this.getBookState(); }, navigateToPagePosition(value) { return navigateToPagePosition(value); }, startPageFlipForTest(direction, options = {}) { return startPageFlip(direction, options); }, advancePageFlipForTest(elapsedMs = normalFlipDuration + 16) { if (!activeFlips.length) return this.getBookState(); const targetNow = activeFlips.reduce((maxTime, flip) => { return Math.max(maxTime, flip.startTime + Math.max(0, Number(elapsedMs || 0))); }, performance.now()); updateActiveFlips(targetNow); return this.getBookState(); }, mapPageToSpread(value) { return pageToSpreadIndex(value); }, mapSpreadToPage(value) { return spreadIndexToPagePosition(value); }, redrawPageTextures() { window.BookTextureRenderer?.publishSpread?.(); return true; }, getRevealDebugState() { return getRevealDebugState(); }, getTextureInfo() { return { pageTextureWidth, pageTextureHeight: leftCanvas.height, debug: getPageTextureDebugState() }; }, getRuntimeInvariants() { return { targetFrameDurationMs, residentPageTextureCount: residentPageTextures.size, maxResidentPageTextures, pageCacheProblemCount: pageCacheProblemLog.length, flipFrontBackShareMaterial: materials.flipPageSurface === materials.flipPageBackSurface, mirrorRefreshesEveryFrame: true, mirrorRefreshesWhenStaticDirty: true }; }, projectPointerToPage(clientX, clientY) { return projectPointerToPage(clientX, clientY); }, exportTexture(name) { if (name === 'left' || name === 'leftPage') return leftTexture.image?.toDataURL?.('image/png') || leftCanvas.toDataURL('image/png'); if (name === 'right' || name === 'rightPage') return rightTexture.image?.toDataURL?.('image/png') || rightCanvas.toDataURL('image/png'); return generatedTextureCanvases[name]?.toDataURL('image/png') || null; } }; window.addEventListener('resize', resize); document.addEventListener('webgl-book:page-canvases', handlePageCanvases); document.addEventListener('webgl-book:page-reveal-start', (event) => { startPageRevealForBlock(event.detail?.blockId); }); document.addEventListener('webgl-book:page-reveal-fast-forward', (event) => { fastForwardPageReveals(event.detail?.blockIds || []); }); document.addEventListener('webgl-book:reveal-committed', (event) => { handleRevealCommittedForPageFlip(event.detail || {}); }); document.addEventListener('webgl-book:page-cache-problem', (event) => { recordPageCacheProblem(event.detail || {}); }); document.addEventListener('book-pagination:spread-updated', (event) => { const detail = event.detail || {}; const previousPageCount = bookPageCount; bookPaginationState = { spreadIndex: Math.max(0, Number(detail.spreadIndex || 0)), spreadCount: Math.max(1, Number(detail.spreadCount || 1)), writtenPageLimit: Math.max(0, Number(detail.writtenPageLimit || 0)) }; growBookIfWritableLimitReached(); if (bookPageCount !== previousPageCount) { buildBook(); notifyBookPageCountChanged(); window.WebGLBookPreferenceBridge?.updatePageCount?.(bookPageCount); } syncBookControls(); if (pendingRightPageFlip) tryStartPendingRightPageFlip('spread-updated'); }); document.addEventListener('webgl-book:page-reserve-directive', (event) => { const detail = event.detail || {}; const value = Number(detail.value); if (!Number.isFinite(value)) return; const nextReserve = detail.unit === 'percent' ? Math.round(bookPageCount * (value / 100)) : Math.round(value); setPageReserve(nextReserve); }); document.addEventListener('webgl-book:request-page-flip', (event) => { const detail = event.detail || {}; const direction = Math.sign(Number(detail.direction || 1)) || 1; const targetSpread = Number.isFinite(Number(detail.targetSpread)) ? Math.max(0, Math.round(Number(detail.targetSpread))) : null; startPageFlip(direction, { force: detail.force === true, targetSpread }); }); document.addEventListener('ui:command', (event) => { if (event.detail?.type === 'continue' && pendingRightPageFlip) { tryStartPendingRightPageFlip('continue', { force: true }); } }); installBookControls(); installCameraControls(); resize(); document.dispatchEvent(new CustomEvent('webgl-book:scene-ready')); ensureFpsDisplay(); animate(); function ensureFpsDisplay() { if (fpsDisplay) return fpsDisplay; fpsDisplay = document.createElement('div'); fpsDisplay.id = 'webgl_fps_display'; Object.assign(fpsDisplay.style, { position: 'fixed', top: '0.65rem', right: '0.75rem', zIndex: '80', minWidth: '4.2rem', padding: '0.22rem 0.42rem', border: '1px solid rgba(246, 231, 201, 0.28)', background: 'rgba(10, 7, 4, 0.62)', color: 'rgba(255, 238, 202, 0.94)', font: '12px ui-monospace, SFMono-Regular, Consolas, monospace', lineHeight: '1.2', textAlign: 'right', pointerEvents: 'none' }); fpsDisplay.textContent = '0 fps'; document.body.appendChild(fpsDisplay); return fpsDisplay; } 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', { maxSize: 2048 }); tableDustTexture.wrapS = THREE.ClampToEdgeWrapping; tableDustTexture.wrapT = THREE.ClampToEdgeWrapping; tableDustTexture.anisotropy = renderer.capabilities.getMaxAnisotropy(); tableGreaseTexture = loadUtilityTexture('/assets/webgl/table_grease_4k.png', { maxSize: 2048 }); 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, options = {}) { const texture = new THREE.TextureLoader().load(url, (loadedTexture) => { const maxSize = Math.max(0, Number(options.maxSize || 0)); const image = loadedTexture.image; const width = Number(image?.naturalWidth || image?.width || 0); const height = Number(image?.naturalHeight || image?.height || 0); if (!maxSize || !width || !height || (width <= maxSize && height <= maxSize)) return; const scale = Math.min(maxSize / width, maxSize / height); const canvas = document.createElement('canvas'); canvas.width = Math.max(1, Math.round(width * scale)); canvas.height = Math.max(1, Math.round(height * scale)); canvas.getContext('2d')?.drawImage(image, 0, 0, canvas.width, canvas.height); loadedTexture.image = canvas; loadedTexture.needsUpdate = true; }); 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; const pageReveal = material.userData?.bookPageReveal || null; material.customProgramCacheKey = () => `book-shadow-receiver-${strength.toFixed(2)}-${pageReveal ? 'page-reveal-line-v1' : 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 }; if (pageReveal) { shader.uniforms.bookRevealActive = { value: 0 }; shader.uniforms.bookRevealElapsedMs = { value: completedRevealElapsedMs }; shader.uniforms.bookRevealRegionCount = { value: 0 }; shader.uniforms.bookRevealRegionRects = { value: Array.from({ length: maxRevealRegions }, () => new THREE.Vector4(0, 0, 0, 0)) }; shader.uniforms.bookRevealRegionTimings = { value: Array.from({ length: maxRevealRegions }, () => new THREE.Vector4(0, 1, 0, 0)) }; shader.uniforms.bookRevealPaperColor = { value: paperColor.clone() }; shader.uniforms.bookRevealBaseMap = { value: leftTexture }; shader.uniforms.bookRevealUseBaseMap = { value: 0 }; shader.uniforms.bookRevealSoftness = { value: 0.025 }; material.userData.bookRevealShader = shader; applyPendingPageReveal(pageReveal.side, shader); } shader.vertexShader = shader.vertexShader .replace( '#include ', `#include varying vec3 vBookReceiverWorldPosition; varying vec3 vBookReceiverWorldNormal; ${isSpineCloth || isHardcoverPaper || isHeadband ? 'varying vec2 vBookSurfaceUv;' : ''}` ) .replace( '#include ', `#include vBookReceiverWorldNormal = normalize(mat3(modelMatrix) * objectNormal);` ) .replace( '#include ', `${isSpineCloth || isHardcoverPaper || isHeadband ? 'vBookSurfaceUv = uv;' : ''} #include ` ) .replace( '#include ', 'vBookReceiverWorldPosition = (modelMatrix * vec4(transformed, 1.0)).xyz;\n#include ' ); shader.fragmentShader = shader.fragmentShader .replace( '#include ', `#include uniform sampler2D bookShadowMaps[3]; uniform mat4 bookShadowMatrices[3]; uniform vec2 bookShadowMapTexelSize; uniform float bookShadowReceiverStrength; uniform float bookTableTopY; ${pageReveal ? `uniform float bookRevealActive; uniform float bookRevealElapsedMs; uniform int bookRevealRegionCount; uniform vec4 bookRevealRegionRects[128]; uniform vec4 bookRevealRegionTimings[128]; uniform vec3 bookRevealPaperColor; uniform sampler2D bookRevealBaseMap; uniform float bookRevealUseBaseMap; uniform float bookRevealSoftness; float bookRevealVisibleMask(vec2 uv) { float hidden = 0.0; for (int i = 0; i < 128; i++) { float enabled = step(float(i) + 0.5, float(bookRevealRegionCount)); vec4 rect = bookRevealRegionRects[i]; vec2 local = (uv - rect.xy) / max(rect.zw, vec2(0.0001)); float inside = step(0.0, local.x) * step(0.0, local.y) * step(local.x, 1.0) * step(local.y, 1.0); vec4 timing = bookRevealRegionTimings[i]; float progress = clamp((bookRevealElapsedMs - timing.x) / max(1.0, timing.y), 0.0, 1.0); float scan = clamp(local.x * 0.96 + (1.0 - local.y) * 0.04, 0.0, 1.0); float feather = max(0.0001, bookRevealSoftness); float visible = smoothstep(scan - feather, scan + feather, progress); hidden = max(hidden, enabled * inside * (1.0 - visible)); } return hidden; }` : ''} 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.026, 0.024, 0.021) * tableFill; vec3 roomWarmth = vec3(0.024, 0.024, 0.023) * sideFill; vec3 pageWarmth = vec3(0.022, 0.022, 0.02) * pageFill * grazingSide * (1.0 - upFacing * 0.42); vec3 indirect = tableWarmth + roomWarmth + pageWarmth; return albedo * indirect * mix(1.0, 0.92, 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.005 + cloud * 0.007, -0.012, 0.014); vec3 paperTint = mix(vec3(0.965, 0.955, 0.915), vec3(1.01, 1.0, 0.96), clamp(0.5 + 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 ', `${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)' : isHardcoverPaper ? 'vec3(0.82, 0.78, 0.68)' : 'vec3(0.38, 0.29, 0.2)'}, bookReceiverShadow); outgoingLight += bookLocalBounce(vBookReceiverWorldPosition, normalize(vBookReceiverWorldNormal), bookReceiverShadow, diffuseColor.rgb); #include ` ); if (pageReveal) { shader.fragmentShader = shader.fragmentShader.replace( '#include ', `#ifdef USE_MAP vec4 sampledDiffuseColor = texture2D(map, vMapUv); if (bookRevealActive > 0.5) { float hiddenInk = bookRevealVisibleMask(vMapUv); float luminance = dot(sampledDiffuseColor.rgb, vec3(0.2126, 0.7152, 0.0722)); float inkMask = 1.0 - smoothstep(0.52, 0.9, luminance); vec3 revealBaseColor = mix(bookRevealPaperColor, texture2D(bookRevealBaseMap, vMapUv).rgb, bookRevealUseBaseMap); sampledDiffuseColor.rgb = mix(sampledDiffuseColor.rgb, revealBaseColor, clamp(hiddenInk * inkMask * 1.55, 0.0, 1.0)); } diffuseColor *= sampledDiffuseColor; #endif` ); } }; } function configureScenePostprocessing() { sceneComposerTarget = new THREE.WebGLRenderTarget(1, 1, { colorSpace: THREE.SRGBColorSpace, depthBuffer: true, stencilBuffer: false, samples: renderer.capabilities.isWebGL2 ? (isAppIntegrationMode ? 0 : 8) : 0 }); sceneComposerTarget.texture.colorSpace = THREE.SRGBColorSpace; sceneComposerTarget.texture.minFilter = THREE.LinearFilter; sceneComposerTarget.texture.magFilter = THREE.LinearFilter; composer = new EffectComposer(renderer, sceneComposerTarget); composer.setPixelRatio(appRenderPixelRatio); 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.userData = sceneAoPass.userData || {}; sceneAoPass.userData.cachedRenderPass = renderAoPass; sceneAoPass.render = (...args) => { if (!staticSceneBuffersDirty && activeFlips.length === 0) { renderCachedAoPass(sceneAoPass, ...args); return; } 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 ', '#include \nvarying float vWaxLocalY;\nvarying vec3 vWaxWorldPosition;\nvarying vec3 vWaxWorldNormal;' ) .replace( '#include ', '#include \nvWaxLocalY = position.y;' ) .replace( '#include ', '#include \nvWaxWorldNormal = normalize(mat3(modelMatrix) * objectNormal);' ) .replace( '#include ', 'vWaxWorldPosition = (modelMatrix * vec4(transformed, 1.0)).xyz;\n#include ' ); shader.fragmentShader = shader.fragmentShader .replace( '#include ', `#include uniform float waxHeight; uniform vec3 waxFlameWorldPosition; uniform vec3 waxBodyWorldPosition; uniform float waxLightPower; varying float vWaxLocalY; varying vec3 vWaxWorldPosition; varying vec3 vWaxWorldNormal;` ) .replace( '#include ', `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 ` ); }; 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 ', '#include \nuniform mat4 sceneReflectionMatrix;\nvarying vec3 vTableWorldPosition;\nvarying vec4 vSceneReflectionCoord;' ) .replace( '#include ', 'vec4 tableWorldPosition = modelMatrix * vec4(transformed, 1.0);\nvTableWorldPosition = tableWorldPosition.xyz;\nvSceneReflectionCoord = sceneReflectionMatrix * tableWorldPosition;\n#include ' ); shader.fragmentShader = shader.fragmentShader .replace( '#include ', `#include 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 ', `#include 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 ', `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 ` ); }; } function buildBook() { markStaticSceneBuffersDirty(); 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); document.documentElement.dataset.webglBookThickness = JSON.stringify(currentProceduralBookModel.thickness); } function renderCachedAoPass(pass, rendererInstance, writeBuffer, readBuffer) { if (!pass.copyMaterial || !pass.renderPass || !pass.blurRenderTarget) { pass.userData?.cachedRenderPass?.(rendererInstance, writeBuffer, readBuffer); return; } if (pass.output === SSAOPass.OUTPUT?.Default) { pass.copyMaterial.uniforms.tDiffuse.value = readBuffer.texture; pass.copyMaterial.blending = THREE.NoBlending; pass.renderPass(rendererInstance, pass.copyMaterial, pass.renderToScreen ? null : writeBuffer); pass.copyMaterial.uniforms.tDiffuse.value = pass.blurRenderTarget.texture; pass.copyMaterial.blending = THREE.CustomBlending; pass.renderPass(rendererInstance, pass.copyMaterial, pass.renderToScreen ? null : writeBuffer); return; } const texture = pass.output === SSAOPass.OUTPUT?.SSAO ? pass.ssaoRenderTarget?.texture : pass.output === SSAOPass.OUTPUT?.Blur ? pass.blurRenderTarget.texture : pass.output === SSAOPass.OUTPUT?.Normal ? pass.normalRenderTarget?.texture : null; if (!texture) { pass.userData?.cachedRenderPass?.(rendererInstance, writeBuffer, readBuffer); return; } pass.copyMaterial.uniforms.tDiffuse.value = texture; pass.copyMaterial.blending = THREE.NoBlending; pass.renderPass(rendererInstance, pass.copyMaterial, pass.renderToScreen ? null : writeBuffer); } function markStaticSceneBuffersDirty() { staticSceneBuffersDirty = true; } 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(useEdgeMap ? 0.008 : 0.006, useEdgeMap ? 0.008 : 0.006); material.roughnessMap = paperTextures.roughness; material.roughness = Math.max(material.roughness ?? 0.94, useEdgeMap ? 0.96 : 0.94); material.metalness = 0; material.envMapIntensity = Math.min(material.envMapIntensity ?? 0.012, 0.02); 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 clampPageReserve(value, pageCount = bookPageCount) { const parsed = Math.round(Number(value)); if (!Number.isFinite(parsed)) return 50; return THREE.MathUtils.clamp(parsed, 0, Math.max(0, Math.floor(Number(pageCount) || 0))); } function pageToSpreadIndex(pagePosition) { const page = Math.max(0, Math.round(Number(pagePosition || 0))); return page <= 0 ? 0 : Math.floor(page / 2) + 1; } function spreadIndexToPagePosition(spreadIndex) { const spread = Math.max(0, Math.round(Number(spreadIndex || 0))); if (spread <= 0) return 0; if (spread === 1) return 1; return (spread - 1) * 2; } function getWritablePageLimit() { return Math.max(0, bookPageCount - pageReserve); } function getCurrentPagePosition() { return spreadIndexToPagePosition(bookPaginationState.spreadIndex); } function syncReadingProgressToCurrentPage() { const nextProgress = THREE.MathUtils.clamp(getCurrentPagePosition() / Math.max(1, bookPageCount), 0, 1); if (Math.abs(nextProgress - readingProgress) < 0.0001) return; readingProgress = nextProgress; buildBook(); window.WebGLBookPreferenceBridge?.updateProgress?.(readingProgress); } function growBookIfWritableLimitReached() { const writtenLimit = Math.max(0, bookPaginationState.writtenPageLimit || 0); while (writtenLimit >= getWritablePageLimit() && bookPageCount < PROCEDURAL_BOOK.PAGE_COUNT_MAX) { bookPageCount = snapProceduralPageCount(bookPageCount + PROCEDURAL_BOOK.PAGE_COUNT_STEP); } } function setBookPageCount(value) { const nextPageCount = snapProceduralPageCount(value); if (!Number.isFinite(nextPageCount)) return; bookPageCount = Math.max(nextPageCount, bookPageCount); pageReserve = clampPageReserve(pageReserve, bookPageCount); growBookIfWritableLimitReached(); buildBook(); notifyBookPageCountChanged(); syncBookControls(); window.WebGLBookPreferenceBridge?.updatePageCount?.(bookPageCount); } function setPageReserve(value) { pageReserve = clampPageReserve(value, bookPageCount); growBookIfWritableLimitReached(); buildBook(); notifyBookPageCountChanged(); syncBookControls(); window.WebGLBookPreferenceBridge?.updatePageReserve?.(pageReserve); window.WebGLBookPreferenceBridge?.updatePageCount?.(bookPageCount); } function notifyBookPageCountChanged() { document.dispatchEvent(new CustomEvent('webgl-book:page-count-changed', { detail: { pageCount: bookPageCount, model: currentProceduralBookModel } })); } function stepReadingProgress(pageDelta) { setReadingProgress(readingProgress + pageDelta / Math.max(1, bookPageCount)); } function installBookControls() { ensureBottomNavigation(); if (progressInput) { progressInput.value = readingProgress.toFixed(3); progressInput.addEventListener('input', () => setReadingProgress(progressInput.value)); } if (pageCountInput) { 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); 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 ensureBottomNavigation() { if (bottomNavigation) return bottomNavigation; const root = document.createElement('nav'); root.id = 'webgl_book_navigation'; root.setAttribute('aria-label', appInitialState.t?.('webgl.bookControls') || 'Book controls'); const makeButton = (id, label, icon) => { const button = document.createElement('button'); button.id = id; button.type = 'button'; button.className = 'webgl-book-nav-button'; button.setAttribute('aria-label', label); button.title = label; button.textContent = icon; root.appendChild(button); return button; }; const startButton = makeButton('webgl_book_nav_start', appInitialState.t?.('webgl.returnToBeginning') || 'Return to beginning', '⏮'); const backButton = makeButton('webgl_book_nav_back', appInitialState.t?.('webgl.backward') || 'Backward', '◀'); const sliderWrap = document.createElement('div'); sliderWrap.className = 'webgl-book-nav-slider-wrap'; const minLabel = document.createElement('span'); minLabel.id = 'webgl_book_nav_min_label'; minLabel.className = 'webgl-book-nav-limit-label'; minLabel.textContent = '0'; const sliderTrack = document.createElement('div'); sliderTrack.className = 'webgl-book-nav-slider-track'; const pageLabel = document.createElement('output'); pageLabel.id = 'webgl_book_nav_page_label'; pageLabel.className = 'webgl-book-nav-page-label'; pageLabel.textContent = '0'; const maxLabel = document.createElement('span'); maxLabel.id = 'webgl_book_nav_max_label'; maxLabel.className = 'webgl-book-nav-limit-label'; maxLabel.textContent = String(bookPageCount); const slider = document.createElement('input'); slider.id = 'webgl_book_nav_position'; slider.type = 'range'; slider.min = '0'; slider.step = '1'; slider.value = '0'; sliderTrack.appendChild(minLabel); sliderTrack.appendChild(slider); sliderTrack.appendChild(maxLabel); sliderWrap.appendChild(sliderTrack); sliderWrap.appendChild(pageLabel); root.appendChild(sliderWrap); const forwardButton = makeButton('webgl_book_nav_forward', appInitialState.t?.('webgl.forward') || 'Forward', '▶'); const endButton = makeButton('webgl_book_nav_end', appInitialState.t?.('webgl.goToEnd') || 'Go to end', '⏭'); startButton.addEventListener('click', () => navigateToPagePosition(0)); backButton.addEventListener('click', () => navigateByPageDelta(-1)); forwardButton.addEventListener('click', () => navigateByPageDelta(1)); endButton.addEventListener('click', () => navigateToPagePosition(bookPaginationState.writtenPageLimit)); slider.addEventListener('input', () => { const requested = Number(slider.value); const clamped = Math.min(requested, Math.max(0, bookPaginationState.writtenPageLimit || 0), getWritablePageLimit()); if (requested !== clamped) slider.value = String(clamped); pageLabel.textContent = `${appInitialState.t?.('webgl.page') || 'Page'} ${clamped}`; }); slider.addEventListener('change', () => navigateToPagePosition(Number(slider.value))); document.body.appendChild(root); bottomNavigation = { root, startButton, backButton, slider, minLabel, maxLabel, pageLabel, forwardButton, endButton }; return bottomNavigation; } function navigateByPageDelta(delta) { const current = getCurrentPagePosition(); const next = Math.max(0, current + Math.sign(Number(delta || 0))); return navigateToPagePosition(next); } function navigateToPagePosition(pagePosition) { const writableLimit = getWritablePageLimit(); const writtenLimit = Math.max(0, bookPaginationState.writtenPageLimit || 0); const targetPage = THREE.MathUtils.clamp(Math.round(Number(pagePosition || 0)), 0, Math.min(writableLimit, writtenLimit)); const currentPage = getCurrentPagePosition(); if (targetPage === currentPage) { syncBookControls(); return false; } const targetSpread = pageToSpreadIndex(targetPage); const currentSpread = bookPaginationState.spreadIndex; const spreadDelta = targetSpread - currentSpread; if (Math.abs(spreadDelta) === 1) { return startPageFlip(Math.sign(spreadDelta), { targetSpread }); } return startFastPageFlip(Math.sign(spreadDelta), { targetSpread, skippedSpreads: Math.abs(spreadDelta) }); } 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); syncBottomNavigation(); } function syncBottomNavigation() { if (!bottomNavigation) return; const currentPage = getCurrentPagePosition(); const writtenLimit = Math.max(0, bookPaginationState.writtenPageLimit || 0); const writableLimit = getWritablePageLimit(); const navigableLimit = Math.min(writtenLimit, writableLimit); const reservedStart = Math.max(0, writableLimit); bottomNavigation.slider.max = String(Math.max(0, bookPageCount)); bottomNavigation.slider.value = String(Math.min(currentPage, navigableLimit)); bottomNavigation.minLabel.textContent = '0'; bottomNavigation.maxLabel.textContent = String(bookPageCount); bottomNavigation.pageLabel.textContent = `${appInitialState.t?.('webgl.page') || 'Page'} ${Math.min(currentPage, navigableLimit)}`; bottomNavigation.root.style.setProperty('--book-nav-position', `${bookPageCount > 0 ? currentPage / bookPageCount : 0}`); bottomNavigation.root.style.setProperty('--book-nav-written', `${bookPageCount > 0 ? writtenLimit / bookPageCount : 0}`); bottomNavigation.root.style.setProperty('--book-nav-reserve-start', `${bookPageCount > 0 ? reservedStart / bookPageCount : 1}`); bottomNavigation.root.dataset.bookSize = String(bookPageCount); bottomNavigation.root.dataset.pageReserve = String(pageReserve); bottomNavigation.startButton.disabled = activeFlips.length > 0 || currentPage <= 0; bottomNavigation.backButton.disabled = activeFlips.length > 0 || currentPage <= 0; bottomNavigation.forwardButton.disabled = activeFlips.length > 0 || currentPage >= navigableLimit; bottomNavigation.endButton.disabled = activeFlips.length > 0 || currentPage >= navigableLimit; } function handlePageCanvases(event) { const detail = event.detail || {}; if (detail.pageMeta) { const hasLeftMeta = Object.prototype.hasOwnProperty.call(detail.pageMeta, 'left'); const hasRightMeta = Object.prototype.hasOwnProperty.call(detail.pageMeta, 'right'); currentPageMeta = { left: hasLeftMeta ? detail.pageMeta.left : currentPageMeta.left || null, right: hasRightMeta ? detail.pageMeta.right : currentPageMeta.right || null }; } markPageTextureTiming('handlePageCanvases:start', { hasLeft: Boolean(detail.left), hasRight: Boolean(detail.right), revealSides: Object.keys(detail.reveal || {}), preloadOnly: Boolean(detail.preloadOnly), pageMeta: currentPageMeta }); const leftReveal = attachRevealPageMeta(detail.reveal?.left, detail.pageMeta?.left || currentPageMeta.left || null); const rightReveal = attachRevealPageMeta(detail.reveal?.right, detail.pageMeta?.right || currentPageMeta.right || null); if (detail.preloadOnly) { if (detail.left) { const texture = preloadPageTexture('left', detail.left, leftReveal, detail.pageMeta?.left || null); rememberResidentPageTexture(detail.pageMeta?.left || null, texture, detail.left); } if (detail.right) { const texture = preloadPageTexture('right', detail.right, rightReveal, detail.pageMeta?.right || null); rememberResidentPageTexture(detail.pageMeta?.right || null, texture, detail.right); } markPageTextureTiming('handlePageCanvases:preloadOnly:end'); return; } if (detail.left) { if (leftReveal) { beginPageReveal('left', detail.left, leftReveal); } else { uploadPageTextureDirect('left', detail.left, currentPageMeta.left); } } if (detail.right) { if (rightReveal) { beginPageReveal('right', detail.right, rightReveal); } else { uploadPageTextureDirect('right', detail.right, currentPageMeta.right); } } markStaticSceneBuffersDirty(); document.documentElement.dataset.webglPageTextureMetrics = JSON.stringify({ width: leftCanvas.width, height: leftCanvas.height, source: 'book-texture-renderer' }); markPageTextureTiming('handlePageCanvases:end'); } function attachRevealPageMeta(revealDetail = null, pageMeta = null) { if (!revealDetail) return null; return { ...revealDetail, pageMeta: pageMeta ? { ...pageMeta } : null }; } function getRevealCacheKey(revealDetail = {}) { const ids = Array.isArray(revealDetail.blockIds) ? revealDetail.blockIds : []; const pageIndex = Number(revealDetail.pageMeta?.pageIndex); const pageKey = Number.isFinite(pageIndex) ? `page:${Math.max(0, Math.round(pageIndex))}` : 'page:unknown'; return `${pageKey}:${ids.map(id => String(id)).join('|') || 'direct'}`; } function preloadPageTexture(side, sourceCanvas, revealDetail = {}, pageMeta = null) { if (!sourceCanvas) return null; const texture = createPageCanvasTexture(sourceCanvas); const baseTexture = revealDetail?.baseCanvas ? createPageCanvasTexture(revealDetail.baseCanvas) : null; const key = getRevealCacheKey({ ...(revealDetail || {}), pageMeta: revealDetail?.pageMeta || pageMeta || null }); markPageTextureTiming('preloadTexture:start', { side, key, width: sourceCanvas.width, height: sourceCanvas.height, hasBaseTexture: Boolean(baseTexture) }); preparedPageTextures[side].set(key, { texture, baseTexture, sourceCanvas, revealDetail, uploadedAt: performance.now() }); if (preparedPageTextures[side].size > 128) { const oldestKey = preparedPageTextures[side].keys().next().value; const oldest = preparedPageTextures[side].get(oldestKey); oldest?.texture?.dispose?.(); oldest?.baseTexture?.dispose?.(); preparedPageTextures[side].delete(oldestKey); } markPageTextureTiming('preloadTexture:end', { side, key }); return texture; } function flushPendingRevealStarts() { if (activeFlips.length > 0 || pendingRevealStartBlockIds.size === 0) return; const blockIds = Array.from(pendingRevealStartBlockIds); pendingRevealStartBlockIds.clear(); blockIds.forEach(blockId => startPageRevealForBlock(blockId)); } function setPageFlipActiveFlag() { if (activeFlips.length > 0) { document.documentElement.dataset.webglPageFlipActive = 'true'; } else { delete document.documentElement.dataset.webglPageFlipActive; } } function recordPageCacheProblem(detail = {}) { const entry = { ...detail, at: performance.now() }; pageCacheProblemLog.push(entry); if (pageCacheProblemLog.length > 80) pageCacheProblemLog.splice(0, pageCacheProblemLog.length - 80); document.documentElement.dataset.webglPageCacheProblems = JSON.stringify(pageCacheProblemLog); console.warn('WebGL page cache problem', entry); } function rememberResidentPageTexture(pageMeta = null, texture = null, sourceCanvas = null, ownsTexture = true) { const pageIndex = Number(pageMeta?.pageIndex); if (!texture || !Number.isFinite(pageIndex) || pageIndex < 0) return null; const key = makePageMetaForCache(pageIndex).pageIndex; const existing = residentPageTextures.get(key); if (isOlderPageTextureMeta(pageMeta, existing?.pageMeta)) return existing?.texture || null; if (existing?.ownsTexture && existing.texture && existing.texture !== texture) existing.texture.dispose?.(); residentPageTextures.set(key, { texture, sourceCanvas: sourceCanvas || existing?.sourceCanvas || null, lastUsedAt: performance.now(), ownsTexture, pageMeta: { ...(existing?.pageMeta || {}), ...(pageMeta || {}) } }); while (residentPageTextures.size > maxResidentPageTextures) { const oldestKey = residentPageTextures.keys().next().value; const oldest = residentPageTextures.get(oldestKey); if (oldest?.ownsTexture) oldest.texture?.dispose?.(); residentPageTextures.delete(oldestKey); } return texture; } function isOlderPageTextureMeta(incoming = {}, existing = null) { if (!existing) return false; const incomingCompleteness = Math.max(0, Number(incoming?.completenessScore || 0)); const existingCompleteness = Math.max(0, Number(existing?.completenessScore || 0)); if (incomingCompleteness < existingCompleteness) return true; if (incomingCompleteness > existingCompleteness) return false; const incomingVersion = Math.max(0, Number(incoming?.contentVersion || 0)); const existingVersion = Math.max(0, Number(existing?.contentVersion || 0)); return incomingVersion > 0 && existingVersion > incomingVersion; } function makePageMetaForCache(pageIndex) { return { pageIndex: Math.max(0, Math.round(Number(pageIndex || 0))), width: pageTextureWidth, height: leftCanvas?.height || Math.round(pageTextureWidth * PROCEDURAL_BOOK.PAGE_DEPTH / PROCEDURAL_BOOK.PAGE_WIDTH) }; } function spreadPageIndices(spreadIndex) { const spread = Math.max(0, Math.round(Number(spreadIndex || 0))); return { left: spread * 2, right: spread * 2 + 1 }; } function getResidentPageTexture(pageIndex) { const key = makePageMetaForCache(pageIndex).pageIndex; const resident = residentPageTextures.get(key); if (!resident) return null; resident.lastUsedAt = performance.now(); residentPageTextures.delete(key); residentPageTextures.set(key, resident); return resident.texture || null; } function getResidentPageTextureForMeta(pageMeta = null) { const pageIndex = Number(pageMeta?.pageIndex); if (!Number.isFinite(pageIndex)) return null; const key = makePageMetaForCache(pageIndex).pageIndex; const resident = residentPageTextures.get(key); if (!resident || isOlderPageTextureMeta(pageMeta, resident.pageMeta)) return null; return getResidentPageTexture(pageIndex); } async function preloadCachedPageTexture(pageIndex) { const meta = makePageMetaForCache(pageIndex); if (residentPageTextures.has(meta.pageIndex)) { getResidentPageTexture(meta.pageIndex); return residentPageTextures.get(meta.pageIndex)?.texture || null; } const cache = window.WebGLPageCache || window.moduleRegistry?.getModule?.('webgl-page-cache') || null; const sourceCanvas = await cache?.getPageCanvas?.(meta); if (!sourceCanvas) { recordPageCacheProblem({ type: 'db-cache-miss', pageIndex: meta.pageIndex, width: meta.width, height: meta.height }); return null; } const texture = createPageCanvasTexture(sourceCanvas); const cachedMeta = sourceCanvas.__webglPageCacheMeta || meta; residentPageTextures.set(meta.pageIndex, { texture, sourceCanvas, lastUsedAt: performance.now(), ownsTexture: true, pageMeta: cachedMeta }); while (residentPageTextures.size > maxResidentPageTextures) { const oldestKey = residentPageTextures.keys().next().value; const oldest = residentPageTextures.get(oldestKey); if (oldest?.ownsTexture) oldest.texture?.dispose?.(); residentPageTextures.delete(oldestKey); } return texture; } async function prewarmSpreadTextures(spreadIndex) { const indices = spreadPageIndices(spreadIndex); const [left, right] = await Promise.all([ preloadCachedPageTexture(indices.left), preloadCachedPageTexture(indices.right) ]); return { spreadIndex: Math.max(0, Math.round(Number(spreadIndex || 0))), left, right }; } async function prewarmFlipTextures(direction, targetSpread = null) { const currentSpread = Math.max(0, Number(bookPaginationState.spreadIndex || 0)); const nextSpread = Number.isFinite(Number(targetSpread)) ? Math.max(0, Math.round(Number(targetSpread))) : Math.max(0, currentSpread + Math.sign(Number(direction || 0))); const [current, next] = await Promise.all([ prewarmSpreadTextures(currentSpread), prewarmSpreadTextures(nextSpread) ]); return { current, next }; } function takePreparedPageTexture(side, revealDetail = {}) { const key = getRevealCacheKey(revealDetail); const prepared = preparedPageTextures[side].get(key); if (!prepared) return null; preparedPageTextures[side].delete(key); markPageTextureTiming('preloadTexture:activate', { side, key }); return prepared; } function uploadPageTextureDirect(side, sourceCanvas, pageMeta = null) { const texture = side === 'left' ? leftTexture : rightTexture; const material = side === 'left' ? materials.leftPage : materials.rightPage; const residentTexture = Number.isFinite(Number(pageMeta?.pageIndex)) ? getResidentPageTextureForMeta(pageMeta) : null; markPageTextureTiming('directUpload:start', { side, pageIndex: pageMeta?.pageIndex ?? null, usedResidentTexture: Boolean(residentTexture) }); clearPageReveal(side, 'direct-upload'); if (residentTexture) { if (material.map !== residentTexture) { material.map = residentTexture; material.needsUpdate = true; } markPageTextureTiming('directUpload:end', { side, usedResidentTexture: true }); return; } if (material.map !== texture) { material.map = texture; material.needsUpdate = true; } bindPageTextureSource(side, texture, sourceCanvas); rememberResidentPageTexture(pageMeta, texture, sourceCanvas, false); markPageTextureTiming('directUpload:end', { side }); } function beginPageReveal(side, sourceCanvas, revealDetail = {}) { const texture = side === 'left' ? leftTexture : rightTexture; const shader = getPageRevealShader(side); const material = side === 'left' ? materials.leftPage : materials.rightPage; const prepared = takePreparedPageTexture(side, revealDetail); markPageTextureTiming('revealUpload:start', { side, regionCount: Array.isArray(revealDetail.lineRects) ? revealDetail.lineRects.length : 0, usedPreparedTexture: Boolean(prepared), usedPreparedBaseTexture: Boolean(prepared?.baseTexture) }); if (prepared?.texture) { material.map = prepared.texture; } else { if (material.map !== texture) { material.map = texture; material.needsUpdate = true; } bindPageTextureSource(side, texture, sourceCanvas); } const baseTexture = prepared?.baseTexture || (revealDetail?.baseCanvas ? createPageCanvasTexture(revealDetail.baseCanvas) : null); const revealBlockIds = Array.isArray(revealDetail.blockIds) ? revealDetail.blockIds.map(value => String(value)) : []; const activeStartedAt = revealBlockIds .map(blockId => activeRevealBlockStarts.get(blockId)) .filter(value => Number.isFinite(Number(value))) .sort((a, b) => a - b)[0] ?? null; pageRevealState[side] = { startedAt: activeStartedAt ?? (revealDetail.startNow ? performance.now() : null), pendingStart: false, lastRevealFrameAt: null, visualElapsedMs: activeStartedAt ? Math.max(0, performance.now() - activeStartedAt) : 0, durationMs: Math.max(1, Number(revealDetail.durationMs || 1)), blockIds: revealBlockIds, baseTexture, fastForwarding: false, fastForwardStartedAt: null, fastForwardStartElapsedMs: 0, fastForwardDurationMs: 260 }; if (material?.userData) material.userData.pendingPageReveal = revealDetail; if (shader?.uniforms) applyPendingPageReveal(side, shader); else if (material) material.needsUpdate = true; if (shader?.uniforms?.bookRevealElapsedMs) { shader.uniforms.bookRevealElapsedMs.value = pageRevealState[side].visualElapsedMs; } if (side === 'right' && isRightBodyPageComplete()) { const targetSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)) + 1); prewarmFlipTextures(1, targetSpread).then(() => { markPageTextureTiming('rightPageReveal:flip-prewarm-ready', { targetSpread }); }).catch((error) => { recordPageCacheProblem({ type: 'right-page-flip-prewarm-error', targetSpread, message: error?.message || String(error) }); }); } document.documentElement.dataset.webglRevealDebug = JSON.stringify({ side, blockIds: pageRevealState[side].blockIds, regionCount: Array.isArray(revealDetail.lineRects) ? revealDetail.lineRects.length : 0, shaderReady: Boolean(shader?.uniforms), started: pageRevealState[side].startedAt != null }); markPageTextureTiming('revealUpload:end', { side }); } function applyPendingPageReveal(side, shader = getPageRevealShader(side)) { const material = side === 'left' ? materials.leftPage : materials.rightPage; const revealDetail = material?.userData?.pendingPageReveal; if (!revealDetail || !shader?.uniforms) return false; applyPageRevealRegions(shader, revealDetail.lineRects || []); shader.uniforms.bookRevealActive.value = 1; shader.uniforms.bookRevealElapsedMs.value = 0; const baseTexture = pageRevealState[side]?.baseTexture; if (shader.uniforms.bookRevealBaseMap) shader.uniforms.bookRevealBaseMap.value = baseTexture || (side === 'left' ? leftTexture : rightTexture); if (shader.uniforms.bookRevealUseBaseMap) shader.uniforms.bookRevealUseBaseMap.value = baseTexture ? 1 : 0; document.documentElement.dataset.webglRevealDebug = JSON.stringify({ side, blockIds: pageRevealState[side]?.blockIds || revealDetail.blockIds || [], regionCount: Array.isArray(revealDetail.lineRects) ? revealDetail.lineRects.length : 0, shaderReady: true, started: pageRevealState[side]?.startedAt != null }); delete material.userData.pendingPageReveal; return true; } function applyPageRevealRegions(shader, regions = []) { const rectUniforms = shader.uniforms.bookRevealRegionRects.value; const timingUniforms = shader.uniforms.bookRevealRegionTimings.value; const source = Array.isArray(regions) ? regions : []; if (source.length > maxRevealRegions) { throw new Error(`WebGL reveal region count ${source.length} exceeds architectural maximum ${maxRevealRegions}`); } shader.uniforms.bookRevealRegionCount.value = source.length; source.forEach((region, index) => { const rect = region.rect || {}; const timing = region.timing || {}; const delay = Math.max(0, Number(timing.delay || 0)); const duration = Math.max(1, Number(timing.duration || 1)); const x = THREE.MathUtils.clamp(Number(rect.x || 0), 0, 1); const y = THREE.MathUtils.clamp(Number(rect.y || 0), 0, 1); const width = THREE.MathUtils.clamp(Number(rect.width || 0), 0, 1); const height = THREE.MathUtils.clamp(Number(rect.height || 0), 0, 1); rectUniforms[index].set( x, THREE.MathUtils.clamp(1 - y - height, 0, 1), Math.max(0.0001, width), Math.max(0.0001, height) ); timingUniforms[index].set( delay, duration, 0, 0 ); }); for (let index = source.length; index < maxRevealRegions; index += 1) { rectUniforms[index].set(0, 0, 0, 0); timingUniforms[index].set(0, 1, 0, 0); } } function getPageRevealShader(side) { const material = side === 'left' ? materials.leftPage : materials.rightPage; return material?.userData?.bookRevealShader || null; } function getRevealDebugState() { return ['left', 'right'].reduce((state, side) => { const shader = getPageRevealShader(side); const uniforms = shader?.uniforms || {}; state[side] = { active: Number(uniforms.bookRevealActive?.value || 0), elapsedMs: Number(uniforms.bookRevealElapsedMs?.value || 0), visualElapsedMs: Number(pageRevealState[side]?.visualElapsedMs || 0), regionCount: Number(uniforms.bookRevealRegionCount?.value || 0), usesBaseTexture: Number(uniforms.bookRevealUseBaseMap?.value || 0), fastForwarding: pageRevealState[side]?.fastForwarding === true, started: pageRevealState[side]?.startedAt != null, pendingStart: pageRevealState[side]?.pendingStart === true, durationMs: Number(pageRevealState[side]?.durationMs || 0), blockIds: pageRevealState[side]?.blockIds || [] }; return state; }, {}); } function clearPageReveal(side, reason = 'clear') { const previousState = pageRevealState[side]; pageRevealClearLog.push({ side, reason, at: performance.now(), state: previousState ? { started: previousState.startedAt != null, pendingStart: previousState.pendingStart === true, visualElapsedMs: previousState.visualElapsedMs || 0, durationMs: previousState.durationMs, blockIds: previousState.blockIds || [] } : null }); if (pageRevealClearLog.length > 40) pageRevealClearLog.splice(0, pageRevealClearLog.length - 40); document.documentElement.dataset.webglRevealClearLog = JSON.stringify(pageRevealClearLog); pageRevealState[side] = null; const shader = getPageRevealShader(side); if (shader?.uniforms?.bookRevealActive) { shader.uniforms.bookRevealActive.value = 0; shader.uniforms.bookRevealElapsedMs.value = completedRevealElapsedMs; shader.uniforms.bookRevealRegionCount.value = 0; if (shader.uniforms.bookRevealUseBaseMap) shader.uniforms.bookRevealUseBaseMap.value = 0; } previousState?.baseTexture?.dispose?.(); } function startPageRevealForBlock(blockId) { const id = String(blockId ?? ''); if (!id) return; if (!activeRevealBlockStarts.has(id)) activeRevealBlockStarts.set(id, performance.now()); if (activeFlips.length > 0) { pendingRevealStartBlockIds.add(id); markPageTextureTiming('revealStart:deferred-for-flip', { blockId: id, activeFlips: activeFlips.length }); return; } ['left', 'right'].forEach((side) => { const state = pageRevealState[side]; if (!state || state.startedAt != null) return; if (!state.blockIds.map(value => String(value)).includes(id)) return; state.pendingStart = true; state.startedAt = activeRevealBlockStarts.get(id) || performance.now(); const shader = getPageRevealShader(side); if (shader?.uniforms?.bookRevealElapsedMs) shader.uniforms.bookRevealElapsedMs.value = 0; }); } function fastForwardPageReveals(blockIds = []) { const ids = new Set((Array.isArray(blockIds) ? blockIds : []).map(value => String(value))); ['left', 'right'].forEach((side) => { const state = pageRevealState[side]; if (!state) return; const matches = ids.size === 0 || state.blockIds.some(blockId => ids.has(String(blockId))); if (!matches) return; state.fastForwarding = true; state.fastForwardStartedAt = performance.now(); state.fastForwardStartElapsedMs = Math.max(0, Number(state.visualElapsedMs || 0)); state.fastForwardDurationMs = 260; }); } function updatePageRevealAnimations(now) { if (activeFlips.length > 0) return; ['left', 'right'].forEach((side) => { const state = pageRevealState[side]; if (!state) return; const shader = getPageRevealShader(side); if (!shader?.uniforms) { clearPageReveal(side, 'missing-shader'); return; } if (state.pendingStart) { if (state.startedAt == null) state.startedAt = now; state.pendingStart = false; state.lastRevealFrameAt = now; state.visualElapsedMs = Math.max(0, now - state.startedAt); shader.uniforms.bookRevealElapsedMs.value = state.visualElapsedMs; return; } if (state.startedAt == null) { shader.uniforms.bookRevealElapsedMs.value = 0; return; } state.lastRevealFrameAt = now; if (state.fastForwarding) { const fastElapsed = Math.max(0, now - Number(state.fastForwardStartedAt || now)); const fastProgress = THREE.MathUtils.clamp(fastElapsed / Math.max(1, Number(state.fastForwardDurationMs || 1)), 0, 1); state.visualElapsedMs = THREE.MathUtils.lerp( Math.max(0, Number(state.fastForwardStartElapsedMs || 0)), state.durationMs, fastProgress ); } else { state.visualElapsedMs = Math.max(0, now - state.startedAt); } const progress = THREE.MathUtils.clamp(state.visualElapsedMs / state.durationMs, 0, 1); shader.uniforms.bookRevealElapsedMs.value = state.visualElapsedMs; if (progress < 1) return; clearPageReveal(side, 'duration-complete'); document.dispatchEvent(new CustomEvent('webgl-book:reveal-committed', { detail: { side, blockIds: state.blockIds } })); }); } function bindPageTextureSource(side, texture, sourceCanvas) { const fallbackCanvas = side === 'left' ? leftCanvas : rightCanvas; const nextCanvas = sourceCanvas || fallbackCanvas; markPageTextureTiming('bindPageTextureSource:start', { side, width: nextCanvas?.width || 0, height: nextCanvas?.height || 0 }); texture.image = sourceCanvas || fallbackCanvas; texture.needsUpdate = true; updatePageTextureDebugState(side, nextCanvas, sourceCanvas, true); markPageTextureTiming('bindPageTextureSource:end', { side }); } function drawCanvasPageTexture(canvas, sourceCanvas, side) { const ctx = canvas.getContext('2d'); ctx.fillStyle = '#f2ead0'; ctx.fillRect(0, 0, canvas.width, canvas.height); const shade = ctx.createLinearGradient(0, 0, canvas.width, 0); shade.addColorStop(0, 'rgba(70, 48, 28, 0.04)'); shade.addColorStop(side === 'left' ? 0.85 : 0.15, 'rgba(255, 255, 255, 0)'); shade.addColorStop(1, 'rgba(70, 48, 28, 0.04)'); 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: shouldSamplePageTextureDebug() ? countPageTextureDarkPixels(canvas) : null }; document.documentElement.dataset.webglPageTextures = JSON.stringify(state); } function shouldSamplePageTextureDebug() { return tableDebugMode !== tableDebugModes.none; } 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; } async function startPageFlip(direction, options = {}) { if (activeFlips.length || !currentProceduralBookModel) return false; if (!options.force && !canPageFlip(direction)) return false; const targetSpread = Number.isFinite(Number(options.targetSpread)) ? Math.max(0, Math.round(Number(options.targetSpread))) : null; const prewarm = await prewarmFlipTextures(direction, targetSpread); return startPageFlipPrepared(direction, { ...options, targetSpread, prewarm }); } function startPageFlipPrepared(direction, options = {}) { if (activeFlips.length || !currentProceduralBookModel) return false; if (!options.force && !canPageFlip(direction)) return false; const flip = createPageFlip(direction, performance.now(), normalFlipDuration); if (!flip) return false; flip.targetSpread = Number.isFinite(Number(options.targetSpread)) ? Math.max(0, Math.round(Number(options.targetSpread))) : null; if (!prepareStaticPageForFlip(flip, options.prewarm || null)) { return false; } pendingRightPageFlip = false; pendingRightPageFlipAutoplay = false; delete document.documentElement.dataset.webglPendingPageFlip; activeFlips.push(flip); setPageFlipActiveFlag(); syncBookControls(); updateActiveFlips(flip.startTime); return true; } async function startFastPageFlip(direction, options = {}) { if (activeFlips.length || !currentProceduralBookModel || !canPageFlip(direction)) return false; const targetSpread = Number.isFinite(Number(options.targetSpread)) ? Math.max(0, Math.round(Number(options.targetSpread))) : null; const prewarm = await prewarmFlipTextures(direction, targetSpread); return startFastPageFlipPrepared(direction, { ...options, targetSpread, prewarm }); } function startFastPageFlipPrepared(direction, options = {}) { if (activeFlips.length || !currentProceduralBookModel || !canPageFlip(direction)) return false; const firstFlip = createPageFlip(direction, performance.now(), fastFlipDuration); if (!firstFlip) return false; if (!prepareStaticPageForFlip(firstFlip, options.prewarm || null)) return false; const startTime = firstFlip.startTime; const interval = fastFlipDuration / fastFlipOverlap; const skippedSpreads = Math.max(2, Number(options.skippedSpreads || fastFlipCount)); const visibleFlipCount = THREE.MathUtils.clamp(Math.round(skippedSpreads), 2, 5); for (let index = 0; index < visibleFlipCount; index += 1) { activeFlips.push({ ...firstFlip, mesh: null, startTime: startTime + index * interval, pageOffset: index * 0.002, targetSpread: Number.isFinite(Number(options.targetSpread)) ? Math.max(0, Math.round(Number(options.targetSpread))) : null, commitBundleOnFinish: index === visibleFlipCount - 1, countAsPending: false }); } setPageFlipActiveFlag(); syncBookControls(); updateActiveFlips(startTime); return true; } function createPageFlip(direction, startTime, duration) { const sourceSide = direction > 0 ? 1 : -1; const sourcePageSide = direction > 0 ? 'right' : 'left'; const sourceLine = topVisibleLine(sourceSide); const destinationLine = topVisibleLine(-sourceSide); if (!sourceLine || !destinationLine) return null; return { direction, sourcePageSide, sourceLine, destinationLine, startTime, duration, pageOffset: 0, commitBundleOnFinish: false, countAsPending: true, mesh: null }; } function prepareStaticPageForFlip(flip, prewarm = null) { if (!flip) return false; const sourceMaterial = flip.sourcePageSide === 'left' ? materials.leftPage : materials.rightPage; const sourceTexture = sourceMaterial?.map || (flip.sourcePageSide === 'left' ? leftTexture : rightTexture); const targetSpread = Number.isFinite(Number(flip.targetSpread)) ? Math.max(0, Math.round(Number(flip.targetSpread))) : Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)) + Math.sign(Number(flip.direction || 0))); const targetPages = spreadPageIndices(targetSpread); const targetBackPageIndex = flip.direction > 0 ? targetPages.left : targetPages.right; const residentBackTexture = getResidentPageTexture(targetBackPageIndex); const requiresWrittenTexture = targetBackPageIndex <= Math.max(2, Number(bookPaginationState.writtenPageLimit || 0)); if (!residentBackTexture && requiresWrittenTexture) { recordPageCacheProblem({ type: 'flip-back-texture-missing', targetBackPageIndex, targetSpread, direction: flip.direction, prewarmedCurrent: Boolean(prewarm?.current), prewarmedNext: Boolean(prewarm?.next) }); return false; } const backTexture = residentBackTexture || getBlankPageTexture(); materials.flipPageSurface.map = sourceTexture; materials.flipPageBackSurface.map = backTexture; materials.flipPageSurface.normalMap = materials.pageSurface.normalMap; materials.flipPageBackSurface.normalMap = materials.pageSurface.normalMap; materials.flipPageSurface.roughnessMap = materials.pageSurface.roughnessMap; materials.flipPageBackSurface.roughnessMap = materials.pageSurface.roughnessMap; materials.flipPageSurface.needsUpdate = true; materials.flipPageBackSurface.needsUpdate = true; flip.sourceTexture = sourceTexture; flip.backTexture = backTexture; flip.targetBackPageIndex = targetBackPageIndex; if (flip.direction > 0) { const blankTexture = getBlankPageTexture(); if (blankTexture && materials.rightPage.map !== blankTexture) { clearPageReveal('right', 'page-flip-start'); materials.rightPage.map = blankTexture; materials.rightPage.needsUpdate = true; } } else if (flip.direction < 0) { const blankTexture = getBlankPageTexture(); if (blankTexture && materials.leftPage.map !== blankTexture) { clearPageReveal('left', 'page-flip-start'); materials.leftPage.map = blankTexture; materials.leftPage.needsUpdate = true; } } markPageTextureTiming('flipTexturePreflight:ready', { direction: flip.direction, sourceSide: flip.sourcePageSide, targetSpread, targetBackPageIndex, usedResidentBackTexture: Boolean(residentBackTexture) }); return true; } function canPageFlip(direction) { if (!currentProceduralBookModel) return false; const currentPage = getCurrentPagePosition(); const maxNavigablePage = Math.min(Math.max(0, bookPaginationState.writtenPageLimit || 0), getWritablePageLimit()); if (direction > 0) return currentPage < maxNavigablePage; return currentPage > 0; } function handleRevealCommittedForPageFlip(detail = {}) { if (detail.side !== 'right' || !isRightBodyPageComplete()) return; if (activeFlips.length > 0 || pendingRightPageFlip) return; if (isChoiceAwaitingPlayer()) return; const autoplayFlip = isTtsPlaybackActive(); pendingRightPageFlip = true; pendingRightPageFlipAutoplay = autoplayFlip; document.documentElement.dataset.webglPendingPageFlip = 'right'; if (autoplayFlip) { tryStartPendingRightPageFlip('tts-active'); } } async function tryStartPendingRightPageFlip(reason = 'pending', options = {}) { if (!pendingRightPageFlip || activeFlips.length > 0 || isChoiceAwaitingPlayer()) return false; if (!options.force && !pendingRightPageFlipAutoplay) return false; const targetSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)) + 1); const flipped = await startPageFlip(1, { force: options.force === true || pendingRightPageFlipAutoplay, reason, targetSpread }); if (flipped) { pendingRightPageFlip = false; pendingRightPageFlipAutoplay = false; delete document.documentElement.dataset.webglPendingPageFlip; } return flipped; } function isRightBodyPageComplete() { const meta = currentPageMeta?.right || null; if (!meta || meta.section !== 'body' || meta.kind === 'blank' || meta.kind === 'title') return false; const rendererDebug = window.BookTextureRenderer?.currentSpread || null; const rightLines = Array.isArray(rendererDebug?.right) ? rendererDebug.right : []; const maxLine = rightLines.reduce((max, line) => Math.max(max, Number(line?.pageLine || 0) + Math.max(1, Number(line?.lineCount || 1))), 0); const expectedLines = Math.max(1, Number(meta.linesPerPage || 25)); return maxLine >= expectedLines; } function isChoiceAwaitingPlayer() { return document.documentElement.dataset.choiceAwaiting === 'true' || document.body?.dataset?.choiceAwaiting === 'true' || Boolean(document.querySelector('#choice_menu:not([hidden]) .choice, #choice_menu.visible .choice')); } function isTtsPlaybackActive() { const coordinator = window.moduleRegistry?.getModule?.('playback-coordinator') || window.PlaybackCoordinator || null; return Boolean(coordinator?.isPlaying || coordinator?.state === 'playing' || document.documentElement.dataset.ttsPlaying === 'true'); } 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 (!flip.spreadAdvanced && t >= 0.82) { flip.spreadAdvanced = true; const targetSpread = Number.isFinite(Number(flip.targetSpread)) ? Math.max(0, Math.round(Number(flip.targetSpread))) : null; document.dispatchEvent(new CustomEvent('webgl-book:page-flip-near-end', { detail: { direction: flip.direction, sourceSide: flip.sourcePageSide || (flip.direction > 0 ? 'right' : 'left'), targetSpread } })); } 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) { if (!flip.mesh) { const geometry = createFlippingPageGeometry(surface); flip.mesh = new THREE.Mesh(geometry, [ materials.flipPageSurface, materials.flipPageBackSurface, materials.flipPageEdge ]); flip.mesh.castShadow = false; flip.mesh.receiveShadow = false; flip.mesh.userData.bookPart = 'flippingPage'; flip.mesh.userData.isProceduralBookMesh = true; book.add(flip.mesh); return; } if (!updateFlippingPageGeometry(flip.mesh.geometry, surface)) { const geometry = createFlippingPageGeometry(surface); flip.mesh.geometry.dispose(); flip.mesh.geometry = geometry; } } function createFlippingPageGeometry(surface) { const positions = []; const uvs = []; const indices = []; const topIndices = []; const bottomIndices = []; const wallIndices = []; const topGrid = []; const bottomGrid = []; const pageThickness = Math.max(0.0008, Number(PROCEDURAL_BOOK.SHEET_THICKNESS_MODEL || 0.001)); 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, 1 - 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]; topIndices.push(a, c, b, b, c, d); bottomIndices.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]); } indices.push(...topIndices, ...bottomIndices, ...wallIndices); 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.clearGroups(); geometry.addGroup(0, topIndices.length, 0); geometry.addGroup(topIndices.length, bottomIndices.length, 1); geometry.addGroup(topIndices.length + bottomIndices.length, wallIndices.length, 2); geometry.computeVertexNormals(); return geometry; function addWall(topA, topB, bottomA, bottomB) { wallIndices.push(topA, bottomA, topB, topB, bottomA, bottomB); } } function updateFlippingPageGeometry(geometry, surface) { const position = geometry?.getAttribute?.('position'); if (!position || !surface?.length || !surface[0]?.length) return false; const widthSegments = surface.length - 1; const depthSegments = surface[0].length - 1; const expectedVertexCount = (widthSegments + 1) * (depthSegments + 1) * 2; if (position.count !== expectedVertexCount) return false; const pageThickness = Math.max(0.0008, Number(PROCEDURAL_BOOK.SHEET_THICKNESS_MODEL || 0.001)); const array = position.array; let offset = 0; surface.forEach((rowPoints) => { rowPoints.forEach((point) => { array[offset] = point.x; array[offset + 1] = point.y + pageThickness; array[offset + 2] = point.z; offset += 3; array[offset] = point.x; array[offset + 1] = point.y; array[offset + 2] = point.z; offset += 3; }); }); position.needsUpdate = true; geometry.computeVertexNormals(); geometry.computeBoundingSphere(); return true; } function finishActiveFlip(flip) { removeFlipMesh(flip); activeFlips = activeFlips.filter((active) => active !== flip); setPageFlipActiveFlag(); if (activeFlips.length === 0 && Number.isFinite(Number(flip.targetSpread))) { bookPaginationState = { ...bookPaginationState, spreadIndex: Math.max(0, Math.round(Number(flip.targetSpread))) }; syncReadingProgressToCurrentPage(); } document.dispatchEvent(new CustomEvent('webgl-book:page-flip-finished', { detail: { direction: flip.direction, sourceSide: flip.sourcePageSide || (flip.direction > 0 ? 'right' : 'left'), targetSpread: Number.isFinite(Number(flip.targetSpread)) ? Math.max(0, Math.round(Number(flip.targetSpread))) : null } })); flushPendingRevealStarts(); if (flip.commitBundleOnFinish) { if (Number.isFinite(Number(flip.targetSpread))) { syncBookControls(); } else { 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.014 - fleck * 0.018; }; 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.985 + 0.008 * Math.sin(x * 0.017 + y * 0.003) + 0.006 * Math.sin(y * 0.041); const shade = THREE.MathUtils.clamp(0.985 + fiber, 0.92, 1.0); colorImage.data[index] = Math.round(246 * shade * warmth); colorImage.data[index + 1] = Math.round(239 * shade * warmth); colorImage.data[index + 2] = Math.round(216 * 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(240 * shade * line); edgeImage.data[index + 1] = Math.round(230 * shade * line); edgeImage.data[index + 2] = Math.round(194 * 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) * 1.45, (hDown - hUp) * 1.45, 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() { return new Promise((resolve) => { 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; } markStaticSceneBuffersDirty(); const image = texture.image; if (image) { 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); markStaticSceneBuffersDirty(); } resolve(texture); }, undefined, () => { tintAmbientFromCanvas(generatedTextureCanvases.roomReflection); markStaticSceneBuffersDirty(); resolve(tableRoomReflectionTexture); }); }); } function primeSceneForLoader() { markLoaderTiming('primeSceneForLoader:start'); updateCameraRig(0); updateCandleShadowUniforms(); markLoaderTiming('bookShadowMaps:start'); updateBookShadowMaps(); markLoaderTiming('bookShadowMaps:end'); markLoaderTiming('tableReflection:start'); updateTableReflection(); markLoaderTiming('tableReflection:end'); markLoaderTiming('shaderCompile:start'); renderer.compile(scene, camera); markLoaderTiming('shaderCompile:end'); staticSceneBuffersDirty = false; markLoaderTiming('primeSceneForLoader:end'); } 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); const sizeChanged = width !== lastResizeWidth || height !== lastResizeHeight; lastResizeWidth = width; lastResizeHeight = height; if (sizeChanged) markStaticSceneBuffersDirty(); 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(isAppIntegrationMode ? 0.35 : 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); const signature = [ camera.position.x, camera.position.y, camera.position.z, cameraRig.target.x, cameraRig.target.y, cameraRig.target.z, camera.aspect ].map((value) => value.toFixed(4)).join('|'); if (signature !== lastStaticCameraSignature) { lastStaticCameraSignature = signature; markStaticSceneBuffersDirty(); } } 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(now = performance.now()) { const elapsedSinceLastFrame = lastRenderFrameAt ? now - lastRenderFrameAt : targetFrameDurationMs; if (lastRenderFrameAt && elapsedSinceLastFrame < targetFrameDurationMs) { requestAnimationFrame(animate); return; } const frameElapsedMs = lastRenderFrameAt ? elapsedSinceLastFrame : targetFrameDurationMs; lastRenderFrameAt = now; requestAnimationFrame(animate); const delta = Math.min(0.1, frameElapsedMs / 1000); 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); } }); const hadActiveFlips = activeFlips.length > 0; updateActiveFlips(performance.now()); if (hadActiveFlips) markStaticSceneBuffersDirty(); updatePageRevealAnimations(now); updateCandleShadowUniforms(); renderedFrameCount += 1; const shadowStartedAt = performance.now(); updateBookShadowMaps(); lastFrameTiming.shadows = performance.now() - shadowStartedAt; const reflectionStartedAt = performance.now(); const refreshStaticSceneBuffers = staticSceneBuffersDirty || activeFlips.length > 0; updateTableReflection(); lastFrameTiming.reflection = performance.now() - reflectionStartedAt; const renderStartedAt = performance.now(); if (tableDebugMode === tableDebugModes.mirror) { renderer.setRenderTarget(null); renderer.clear(); renderMirrorDebugView(); } else if (composer) { composer.render(); } else { renderer.render(scene, camera); } lastFrameTiming.render = performance.now() - renderStartedAt; lastFrameTiming.total = lastFrameTiming.shadows + lastFrameTiming.reflection + lastFrameTiming.render; if (refreshStaticSceneBuffers && activeFlips.length === 0) { staticSceneBuffersDirty = false; } window.BookLabDebug.renderedFrames += 1; window.BookLabDebug.ready = true; fpsWindowFrames += 1; if (now - fpsWindowStartedAt >= 500) { const fps = Math.round((fpsWindowFrames * 1000) / Math.max(1, now - fpsWindowStartedAt)); ensureFpsDisplay().textContent = `${fps} fps`; document.documentElement.dataset.webglFps = String(fps); fpsWindowFrames = 0; fpsWindowStartedAt = now; } document.documentElement.dataset.webglRenderedFrames = String(window.BookLabDebug.renderedFrames); document.documentElement.dataset.webglFrameTiming = JSON.stringify(lastFrameTiming); }