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=20260610-book-timeline-l'; 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 = 0.72; 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 = 1536; const tableReflectionBaseHeight = 864; const tableReflectionTarget = new THREE.WebGLRenderTarget(tableReflectionBaseWidth, tableReflectionBaseHeight, { colorSpace: THREE.SRGBColorSpace, depthBuffer: true, stencilBuffer: false, samples: renderer.capabilities.isWebGL2 ? 4 : 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 = 1024; 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 dynamicBufferRefreshIntervalMs = 1000 / 30; const flipDynamicBufferGraceMs = 180; let lastBookShadowRefreshAt = -Infinity; let lastTableReflectionRefreshAt = -Infinity; 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; const minRenderFrameIntervalMs = targetFrameDurationMs * 0.5; let lastRenderFrameAt = 0; let fpsDisplay = null; let fpsWindowStartedAt = performance.now(); let fpsWindowFrames = 0; const lastFrameTiming = {}; const slowFrameLog = []; 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 }; let maxVisitedPagePosition = 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(); let lastFlipTexturePreflight = null; 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() { return pageTextureStore?.getBlankTexture?.() || createPageCanvasTexture(createPageCanvas('blank')); } const maxResidentPageTextures = 192; const pageTextureStore = window.moduleRegistry?.getModule?.('webgl-page-cache') || window.WebGLPageCache || null; pageTextureStore?.configureTextureRuntime?.({ THREE, renderer, configureTexture: configurePageCanvasTexture, createBlankCanvas: () => createPageCanvas('blank'), maxResidentTextureCount: maxResidentPageTextures, maxPreparedTextureCount: 128 }); pageTextureStore?.registerVisibleTexture?.('left', leftTexture, leftCanvas); pageTextureStore?.registerVisibleTexture?.('right', rightTexture, rightCanvas); await reportLabStep(50, 'Initializing page texture store VRAM window'); pageTextureStore?.getBlankTexture?.(); await prewarmNavigationTextureWindow('loader-prime', { recordMiss: false }); let currentPageMeta = { left: null, right: null }; const pageRevealState = { left: null, right: null }; let pageRevealFreezeAt = null; const pageRevealClearLog = []; let scheduledBookRebuildFrame = null; 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.FrontSide }), 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.FrontSide; 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.flipPageSurface.userData.bookPageReveal = { side: 'flipFront' }; materials.flipPageBackSurface.userData.bookPageReveal = { side: 'flipBack' }; 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.flipPageSurface); configureHardcoverPaperMaterial(materials.flipPageBackSurface); 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(), maxVisitedPagePosition, 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)) }; maxVisitedPagePosition = Math.max(maxVisitedPagePosition, getCurrentPagePosition()); growBookIfWritableLimitReached(); syncBookControls(); return this.getBookState(); }, setMaxVisitedPagePosition(value) { const page = Math.max(0, Math.round(Number(value || 0))); maxVisitedPagePosition = Math.max(maxVisitedPagePosition, page); syncBookControls(); return maxVisitedPagePosition; }, 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; }, applyPageTextureRecords(detail = {}) { handlePageTextureRecords({ detail }); return true; }, startRevealForBlock(blockId) { startPageRevealForBlock(blockId); return true; }, requestPageFlip(direction = 1, options = {}) { return startPageFlip(direction, options); }, getRevealDebugState() { return getRevealDebugState(); }, getTextureInfo() { return { pageTextureWidth, pageTextureHeight: leftCanvas.height, debug: getPageTextureDebugState() }; }, getRuntimeInvariants() { const textureStoreState = pageTextureStore?.getRuntimeState?.() || {}; return { targetFrameDurationMs, residentPageTextureCount: textureStoreState.residentTextureCount || 0, maxResidentPageTextures, pageCacheProblemCount: textureStoreState.problemCount || 0, preparedPageTextureCount: textureStoreState.preparedTextureCount || 0, singlePageTextureStore: true, flipFrontBackShareMaterial: materials.flipPageSurface === materials.flipPageBackSurface, mirrorRefreshesAtFps: Math.round(1000 / dynamicBufferRefreshIntervalMs), mirrorDefersDuringFlipStartMs: flipDynamicBufferGraceMs, mirrorRefreshesWhenStaticDirty: true, lastFlipTexturePreflight }; }, getBenchmarkState() { return { frameTiming: { ...lastFrameTiming }, slowFrames: slowFrameLog.slice(-20), pageTextureTimings: pageTextureTimings.slice(-40), timeline: window.BookPlaybackTimeline?.getRuntimeState?.()?.benchmark || [] }; }, 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; } }; // Publish the visible spread as a production accessor on the scene module so the // playback owner can read it without touching the debug surface (window.BookLabDebug). const webglBookSceneModule = window.moduleRegistry?.getModule?.('webgl-book-scene') || null; if (webglBookSceneModule) { webglBookSceneModule.getVisibleSpreadIndex = () => Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0))); // Production control surface for the scene host (webgl-book-scene) and save/restore. // window.BookLabDebug remains a debug/inspection-only alias; production code uses this. webglBookSceneModule.sceneControl = { getBookState: () => window.BookLabDebug.getBookState(), setReadingProgress: (value) => setReadingProgress(value), setBookPageCount: (value) => setBookPageCount(value), setPageReserve: (value) => setPageReserve(value), setMaxVisitedPagePosition: (value) => window.BookLabDebug.setMaxVisitedPagePosition(value), redrawPageTextures: () => window.BookLabDebug.redrawPageTextures(), projectPointerToPage: (clientX, clientY) => projectPointerToPage(clientX, clientY) }; } window.addEventListener('resize', resize); document.addEventListener('webgl-book:page-texture-records', handlePageTextureRecords); 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:request-page-flip', (event) => { const detail = event.detail || {}; const direction = Number(detail.direction) || (detail.targetSpread > bookPaginationState.spreadIndex ? 1 : -1); // Let the scene own flip prewarming via prewarmFlipTextures (which draws and // makes resident the current + target spreads). The owner's cache-warming plan // is a different shape and must not be passed through as the flip prewarm. startPageFlip(direction, { force: detail.force === true, reason: detail.reason, targetSpread: detail.targetSpread, deferRevealSides: Array.isArray(detail.revealSides) ? detail.revealSides : null }); }); document.addEventListener('webgl-book:page-cache-problem', (event) => { pageTextureStore?.recordProblem?.(event.detail || {}); }); // Pagination spread updates only carry state. The playback owner decides when the // visible spread changes (via flips). The scene jumps directly only for non-playback // commits such as history restore. See docs/webgl-3d-ui-spec.md "Single ownership". document.addEventListener('book-pagination:spread-updated', (event) => { const detail = event.detail || {}; const incomingSpreadIndex = Math.max(0, Number(detail.spreadIndex || 0)); const playbackActive = document.documentElement.dataset.webglBookPlaybackActive === 'true'; const stateOnly = playbackActive || activeFlips.length > 0 || detail.visibility === 'future-ready'; if (stateOnly) { markPageTextureTiming('spreadUpdate:state-only', { incomingSpreadIndex, visibleSpreadIndex: bookPaginationState.spreadIndex, visibility: detail.visibility || 'current', playbackActive }); bookPaginationState = { ...bookPaginationState, spreadCount: Math.max(1, Number(detail.spreadCount || bookPaginationState.spreadCount || 1)), writtenPageLimit: Math.max( Math.max(0, Number(bookPaginationState.writtenPageLimit || 0)), Math.max(0, Number(detail.writtenPageLimit || 0)) ) }; growBookIfWritableLimitReached(); syncBookControls(); return; } // Non-playback committed update (history restore, continuation reload): jump // directly to the committed spread and paint it. const previousPageCount = bookPageCount; bookPaginationState = { spreadIndex: incomingSpreadIndex, 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); } const spread = detail.spread || getPaginationSpread(incomingSpreadIndex); if (spread) window.BookTextureRenderer?.drawSpread?.(spread, ['left', 'right'], { force: true }); syncBookControls(); markPageTextureTiming('spreadUpdate:jump', { incomingSpreadIndex }); }); 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); }); 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); } // Navigation is spread-based. The highest spread the reader may reach is the lesser of // the spreads they have already visited and the spreads that actually exist (spreadCount // is the real count). This prevents a stale restored position from flipping into empty // pages while still allowing reaching the last existing spread. function getMaxNavigableSpread() { const spreadCount = Math.max(1, Math.round(Number(bookPaginationState.spreadCount || 1))); const visitedSpread = pageToSpreadIndex(maxVisitedPagePosition); return Math.max(0, Math.min(visitedSpread, spreadCount - 1)); } // The page-number readout shows the odd (right) page of the visible pair, or 0 at the // title spread. function spreadPageLabel(spreadIndex) { const spread = Math.max(0, Math.round(Number(spreadIndex || 0))); return spread <= 0 ? '0' : String(spread * 2 + 1); } function scheduleBookRebuild(reason = 'scheduled') { if (scheduledBookRebuildFrame !== null) return; const scheduler = typeof window.requestIdleCallback === 'function' ? (callback) => window.requestIdleCallback(callback, { timeout: 180 }) : requestAnimationFrame; scheduledBookRebuildFrame = scheduler(() => { scheduledBookRebuildFrame = null; markPageTextureTiming('bookRebuild:deferred', { reason }); buildBook(); syncBookControls(); }); } function syncReadingProgressToCurrentPage(options = {}) { const nextProgress = THREE.MathUtils.clamp(getCurrentPagePosition() / Math.max(1, bookPageCount), 0, 1); const changed = Math.abs(nextProgress - readingProgress) >= 0.0001; if (changed) { readingProgress = nextProgress; window.WebGLBookPreferenceBridge?.updateProgress?.(readingProgress); } if (!changed && options.rebuild !== 'defer') return; if (options.rebuild === 'defer') { scheduleBookRebuild(options.reason || 'reading-progress-sync'); return; } if (options.rebuild === false) return; buildBook(); } 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', () => navigateToSpread(0)); backButton.addEventListener('click', () => navigateBySpreadDelta(-1)); forwardButton.addEventListener('click', () => navigateBySpreadDelta(1)); endButton.addEventListener('click', () => navigateToSpread(getMaxNavigableSpread())); slider.addEventListener('input', () => { const requested = Number(slider.value); const clamped = Math.min(requested, getMaxNavigableSpread()); if (requested !== clamped) slider.value = String(clamped); pageLabel.textContent = `${appInitialState.t?.('webgl.page') || 'Page'} ${spreadPageLabel(clamped)}`; }); slider.addEventListener('change', () => navigateToSpread(Number(slider.value))); document.body.appendChild(root); bottomNavigation = { root, startButton, backButton, slider, minLabel, maxLabel, pageLabel, forwardButton, endButton }; return bottomNavigation; } function navigateToSpread(targetSpread) { const maxSpread = getMaxNavigableSpread(); const target = THREE.MathUtils.clamp(Math.round(Number(targetSpread || 0)), 0, maxSpread); const currentSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0))); const spreadDelta = target - currentSpread; if (spreadDelta === 0) { syncBookControls(); return false; } if (Math.abs(spreadDelta) === 1) { return startPageFlip(Math.sign(spreadDelta), { targetSpread: target }); } return startFastPageFlip(Math.sign(spreadDelta), { targetSpread: target, skippedSpreads: Math.abs(spreadDelta) }); } function navigateBySpreadDelta(delta) { const currentSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0))); return navigateToSpread(currentSpread + Math.sign(Number(delta || 0))); } // Compatibility wrappers for the page-position-based external API (save/restore, debug). function navigateToPagePosition(pagePosition) { return navigateToSpread(pageToSpreadIndex(Math.max(0, Math.round(Number(pagePosition || 0))))); } function navigateByPageDelta(delta) { return navigateBySpreadDelta(delta); } 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 currentSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0))); const spreadCount = Math.max(1, Math.round(Number(bookPaginationState.spreadCount || 1))); const maxSpread = getMaxNavigableSpread(); const lastSpread = Math.max(0, spreadCount - 1); const denominator = Math.max(1, lastSpread); bottomNavigation.slider.max = String(lastSpread); bottomNavigation.slider.value = String(Math.min(currentSpread, maxSpread)); bottomNavigation.minLabel.textContent = '0'; bottomNavigation.maxLabel.textContent = spreadPageLabel(lastSpread); bottomNavigation.pageLabel.textContent = `${appInitialState.t?.('webgl.page') || 'Page'} ${spreadPageLabel(currentSpread)}`; bottomNavigation.root.style.setProperty('--book-nav-position', `${currentSpread / denominator}`); bottomNavigation.root.style.setProperty('--book-nav-written', `${maxSpread / denominator}`); bottomNavigation.root.style.setProperty('--book-nav-reserve-start', '1'); bottomNavigation.root.dataset.bookSize = String(bookPageCount); bottomNavigation.root.dataset.pageReserve = String(pageReserve); bottomNavigation.startButton.disabled = activeFlips.length > 0 || currentSpread <= 0; bottomNavigation.backButton.disabled = activeFlips.length > 0 || currentSpread <= 0; bottomNavigation.forwardButton.disabled = activeFlips.length > 0 || currentSpread >= maxSpread; bottomNavigation.endButton.disabled = activeFlips.length > 0 || currentSpread >= maxSpread; } function handlePageTextureRecords(event) { const detail = normalizePageTextureRecordDetail(event.detail || {}); const incomingPageMeta = detail.pageMeta ? normalizePageMetaPair(detail.pageMeta, currentPageMeta) : currentPageMeta; const effectivePageMeta = detail.phase === 'prepare' ? incomingPageMeta : incomingPageMeta; if (detail.phase !== 'prepare' && detail.pageMeta) { currentPageMeta = incomingPageMeta; } markPageTextureTiming('handlePageTextureRecords:start', { hasLeft: Boolean(detail.left), hasRight: Boolean(detail.right), revealSides: Object.keys(detail.reveal || {}), phase: detail.phase || 'activate', pageMeta: effectivePageMeta }); const leftReveal = attachRevealPageMeta(detail.reveal?.left, effectivePageMeta.left || null); const rightReveal = attachRevealPageMeta(detail.reveal?.right, effectivePageMeta.right || null); if (detail.phase === 'prepare') { if (detail.left) { const texture = preloadPageTexture('left', detail.left, leftReveal, effectivePageMeta.left); pageTextureStore?.rememberResidentTexture?.(effectivePageMeta.left, texture, detail.left, true); } else if (effectivePageMeta.left?.kind === 'blank') { pageTextureStore?.rememberResidentTexture?.(effectivePageMeta.left, getBlankPageTexture(), null, false); } if (detail.right) { const texture = preloadPageTexture('right', detail.right, rightReveal, effectivePageMeta.right); pageTextureStore?.rememberResidentTexture?.(effectivePageMeta.right, texture, detail.right, true); } else if (effectivePageMeta.right?.kind === 'blank') { pageTextureStore?.rememberResidentTexture?.(effectivePageMeta.right, getBlankPageTexture(), null, false); } markPageTextureTiming('handlePageTextureRecords:prepare:end'); return; } if (detail.left) { if (leftReveal) { beginPageReveal('left', detail.left, leftReveal); } else { uploadPageTextureDirect('left', detail.left, effectivePageMeta.left); } } if (detail.right) { if (rightReveal) { beginPageReveal('right', detail.right, rightReveal); } else { uploadPageTextureDirect('right', detail.right, effectivePageMeta.right); } } if (!detail.left && effectivePageMeta.left?.kind === 'blank') { applyExplicitBlankPageTexture('left', effectivePageMeta.left, 'page-texture-records'); } if (!detail.right && effectivePageMeta.right?.kind === 'blank') { applyExplicitBlankPageTexture('right', effectivePageMeta.right, 'page-texture-records'); } markStaticSceneBuffersDirty(); document.documentElement.dataset.webglPageTextureMetrics = JSON.stringify({ width: leftCanvas.width, height: leftCanvas.height, source: 'book-texture-renderer' }); markPageTextureTiming('handlePageTextureRecords:end'); prewarmNavigationTextureWindow('page-texture-records', { recordMiss: false }).catch((error) => { pageTextureStore?.recordProblem?.({ type: 'navigation-window-prewarm-error', message: error?.message || String(error) }); }); } function normalizePageTextureRecordDetail(detail = {}) { if (!Array.isArray(detail.records) || detail.records.length === 0) { return { ...detail, phase: detail.phase === 'prepare' ? 'prepare' : 'activate' }; } return detail.records.reduce((normalized, record) => { const side = record?.side === 'right' ? 'right' : 'left'; normalized[side] = record.canvas || normalized[side] || null; normalized.pageMeta[side] = record.pageMeta || detail.pageMeta?.[side] || normalized.pageMeta[side] || null; if (record.reveal) normalized.reveal[side] = record.reveal; return normalized; }, { metrics: detail.metrics, hitMaps: detail.hitMaps || {}, sides: detail.records.map(record => record?.side).filter(Boolean), records: detail.records, reveal: {}, pageMeta: {}, phase: detail.phase === 'prepare' ? 'prepare' : 'activate', preparedFromCache: detail.preparedFromCache === true }); } function normalizePageMetaPair(pageMeta = {}, previousMeta = currentPageMeta) { const spreadIndex = getSpreadIndexFromPageMeta(pageMeta) ?? getSpreadIndexFromPageMeta(previousMeta) ?? Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0))); const pageIndices = spreadPageIndices(spreadIndex); return { left: normalizePageSideMeta(pageMeta.left, pageIndices.left, 'left'), right: normalizePageSideMeta(pageMeta.right, pageIndices.right, 'right') }; } function getSpreadIndexFromPageMeta(pageMeta = {}) { const leftIndex = Number(pageMeta?.left?.pageIndex); if (Number.isFinite(leftIndex)) return Math.floor(Math.max(0, leftIndex) / 2); const rightIndex = Number(pageMeta?.right?.pageIndex); if (Number.isFinite(rightIndex)) return Math.floor(Math.max(0, rightIndex) / 2); return null; } function normalizePageSideMeta(meta = null, pageIndex = 0, side = 'left') { const index = Math.max(0, Math.round(Number(meta?.pageIndex ?? pageIndex))); if (!meta || meta.kind === 'blank') return makeBlankPageMeta(index, meta?.section || (index < 3 ? 'frontmatter' : 'body')); return { ...meta, pageIndex: index, side }; } function makeBlankPageMeta(pageIndex = 0, section = 'body') { return { kind: 'blank', section, pageIndex: Math.max(0, Math.round(Number(pageIndex || 0))), pageNumber: null, omitPageNumber: true, lineCount: 0, maxBlockId: 0, completenessScore: 0, side: Math.max(0, Math.round(Number(pageIndex || 0))) % 2 === 0 ? 'left' : 'right' }; } function applyExplicitBlankPageTexture(side, pageMeta = null, reason = 'blank-page') { const material = side === 'left' ? materials.leftPage : materials.rightPage; const blankTexture = getBlankPageTexture(); clearPageReveal(side, reason); if (material.map !== blankTexture) { material.map = blankTexture; material.needsUpdate = true; } pageTextureStore?.rememberResidentTexture?.(pageMeta || makeBlankPageMeta(side === 'left' ? 0 : 1), blankTexture, null, false); markPageTextureTiming('explicitBlankTexture', { side, pageIndex: pageMeta?.pageIndex ?? null, reason }); return blankTexture; } 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 key = getRevealCacheKey({ ...(revealDetail || {}), pageMeta: revealDetail?.pageMeta || pageMeta || null }); markPageTextureTiming('preloadTexture:start', { side, key, width: sourceCanvas.width, height: sourceCanvas.height, hasBaseTexture: Boolean(revealDetail?.baseCanvas) }); const texture = pageTextureStore?.preparePageTexture?.(side, key, pageMeta, sourceCanvas, revealDetail) || null; 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 makePageMetaForCache(pageIndex) { const index = Math.max(0, Math.round(Number(pageIndex || 0))); const paginationMeta = getPaginationPageMeta(index) || {}; return { ...paginationMeta, pageIndex: index, 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 getPaginationPageMeta(pageIndex) { const index = Math.max(0, Math.round(Number(pageIndex || 0))); const spreadIndex = Math.floor(index / 2); const side = index % 2 === 0 ? 'left' : 'right'; const spread = getPaginationSpread(spreadIndex); return spread?.pageMeta?.[side] || null; } function getPaginationSpread(spreadIndex) { const index = Math.max(0, Math.round(Number(spreadIndex || 0))); const pagination = window.moduleRegistry?.getModule?.('book-pagination') || null; return typeof pagination?.getSpread === 'function' ? pagination.getSpread(index) : Array.isArray(pagination?.spreads) ? pagination.spreads[index] : null; } async function prewarmSpreadTextures(spreadIndex) { return pageTextureStore?.prewarmSpreadTextures?.(spreadIndex, makePageMetaForCache) || { spreadIndex: Math.max(0, Math.round(Number(spreadIndex || 0))), left: null, right: null }; } async function prewarmNavigationTextureWindow(reason = 'navigation-window', options = {}) { const currentSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0))); const endSpread = Math.max( 0, Math.round(Number(bookPaginationState.spreadCount || 1)) - 1, pageToSpreadIndex(maxVisitedPagePosition) ); markPageTextureTiming('textureStorePrewarm:start', { reason, currentSpread, endSpread }); const result = await pageTextureStore?.prewarmNavigationWindow?.({ currentSpread, targetSpread: options.targetSpread, endSpread, getPageMetaForIndex: makePageMetaForCache, recordMiss: options.recordMiss === true }); markPageTextureTiming('textureStorePrewarm:end', { reason, spreadCount: result ? Object.keys(result).length : 0 }); return result || {}; } 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))); prepareSpreadTextureRecordsForFlip(currentSpread); prepareSpreadTextureRecordsForFlip(nextSpread); const windowMap = await prewarmNavigationTextureWindow('flip-prewarm', { targetSpread: nextSpread }); const current = windowMap?.[currentSpread] || await prewarmSpreadTextures(currentSpread); const next = windowMap?.[nextSpread] || await prewarmSpreadTextures(nextSpread); return { current, next }; } function prepareSpreadTextureRecordsForFlip(spreadIndex) { const spread = getPaginationSpread(spreadIndex); if (!spread || typeof window.BookTextureRenderer?.drawSpread !== 'function') return false; if (spreadTextureRecordsReady(spread)) return true; window.BookTextureRenderer.drawSpread(spread, ['left', 'right'], { phase: 'prepare' }); return true; } function spreadTextureRecordsReady(spread = null) { if (!spread?.pageMeta || !pageTextureStore) return false; return ['left', 'right'].every((side) => { const meta = spread.pageMeta?.[side] || null; if (!meta || meta.kind === 'blank') return true; return Boolean(pageTextureStore.getResidentTextureForMeta?.(meta)); }); } function takePreparedPageTexture(side, revealDetail = {}) { const key = getRevealCacheKey(revealDetail); const prepared = pageTextureStore?.takePreparedPageTexture?.(side, key) || null; if (!prepared) return null; markPageTextureTiming('preloadTexture:activate', { side, key }); return prepared; } function uploadPageTextureDirect(side, sourceCanvas, pageMeta = null) { if (pageMeta?.kind === 'blank') { applyExplicitBlankPageTexture(side, pageMeta, 'direct-upload'); return; } const texture = side === 'left' ? leftTexture : rightTexture; const material = side === 'left' ? materials.leftPage : materials.rightPage; const shouldUseResidentTexture = pageMeta?.kind !== 'title'; const residentTexture = shouldUseResidentTexture && Number.isFinite(Number(pageMeta?.pageIndex)) ? pageTextureStore?.getResidentTextureForMeta?.(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); pageTextureStore?.rememberResidentTexture?.(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; material.needsUpdate = true; } else { if (material.map !== texture) { material.map = texture; material.needsUpdate = true; } bindPageTextureSource(side, texture, sourceCanvas); } const baseTexture = prepared?.baseTexture || (revealDetail?.baseCanvas ? pageTextureStore?.createTextureFromCanvas?.(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, pageMeta: revealDetail.pageMeta ? { ...revealDetail.pageMeta } : null, baseTexture, pageFlipAfterReveal: revealDetail.pageFlipAfterReveal === true, 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' && revealDetail.pageFlipAfterReveal === true) { const targetSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)) + 1); prewarmFlipTextures(1, targetSpread).then(() => { markPageTextureTiming('rightPageReveal:flip-prewarm-ready', { targetSpread }); }).catch((error) => { pageTextureStore?.recordProblem?.({ 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('revealState:created', { side, blockIds: pageRevealState[side].blockIds, started: pageRevealState[side].startedAt != null, durationMs: pageRevealState[side].durationMs, regionCount: Array.isArray(revealDetail.lineRects) ? revealDetail.lineRects.length : 0 }); 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 : side === 'right' ? materials.rightPage : side === 'flipFront' ? materials.flipPageSurface : side === 'flipBack' ? materials.flipPageBackSurface : null; return material?.userData?.bookRevealShader || null; } function syncFlipRevealShaderFromSource(sourceSide, targetMaterial = materials.flipPageSurface) { if (!sourceSide || !targetMaterial?.userData) return false; const sourceState = pageRevealState[sourceSide]; const sourceShader = getPageRevealShader(sourceSide); const targetShader = targetMaterial.userData.bookRevealShader || null; if (!targetShader?.uniforms) return false; if (!sourceState || !sourceShader?.uniforms) { // The source page has no active reveal (finished content). Clear any stale reveal // mask left on the flip surface by a previous playback flip, so the full page — // including its last word — shows during the turn. targetShader.uniforms.bookRevealActive.value = 0; targetShader.uniforms.bookRevealRegionCount.value = 0; if (targetShader.uniforms.bookRevealUseBaseMap) targetShader.uniforms.bookRevealUseBaseMap.value = 0; return true; } const sourceUniforms = sourceShader.uniforms; const targetUniforms = targetShader.uniforms; targetUniforms.bookRevealActive.value = sourceUniforms.bookRevealActive?.value || 0; targetUniforms.bookRevealElapsedMs.value = sourceUniforms.bookRevealElapsedMs?.value || sourceState.visualElapsedMs || 0; targetUniforms.bookRevealRegionCount.value = sourceUniforms.bookRevealRegionCount?.value || 0; if (targetUniforms.bookRevealBaseMap) targetUniforms.bookRevealBaseMap.value = sourceUniforms.bookRevealBaseMap?.value || sourceState.baseTexture || targetMaterial.map; if (targetUniforms.bookRevealUseBaseMap) targetUniforms.bookRevealUseBaseMap.value = sourceUniforms.bookRevealUseBaseMap?.value || 0; const sourceRects = sourceUniforms.bookRevealRegionRects?.value || []; const targetRects = targetUniforms.bookRevealRegionRects?.value || []; const sourceTimings = sourceUniforms.bookRevealRegionTimings?.value || []; const targetTimings = targetUniforms.bookRevealRegionTimings?.value || []; for (let index = 0; index < Math.min(sourceRects.length, targetRects.length); index += 1) { targetRects[index].copy(sourceRects[index]); } for (let index = 0; index < Math.min(sourceTimings.length, targetTimings.length); index += 1) { targetTimings[index].copy(sourceTimings[index]); } return true; } function revealStateMatchesPage(side, pageMeta = null) { const statePageIndex = Number(pageRevealState[side]?.pageMeta?.pageIndex); const expectedPageIndex = Number(pageMeta?.pageIndex); return Number.isFinite(statePageIndex) && Number.isFinite(expectedPageIndex) && Math.max(0, Math.round(statePageIndex)) === Math.max(0, Math.round(expectedPageIndex)); } 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', options = {}) { 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; } if (options.preserveBaseTexture !== true) previousState?.baseTexture?.dispose?.(); } function startPageRevealForBlock(blockId) { const id = String(blockId ?? ''); if (!id) return; if (!activeRevealBlockStarts.has(id)) activeRevealBlockStarts.set(id, performance.now()); let matchedSides = 0; 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; matchedSides += 1; state.pendingStart = true; state.startedAt = activeRevealBlockStarts.get(id) || performance.now(); const shader = getPageRevealShader(side); if (shader?.uniforms?.bookRevealElapsedMs) shader.uniforms.bookRevealElapsedMs.value = 0; }); markPageTextureTiming('revealStart:applied', { blockId: id, matchedSides, hasLeftState: Boolean(pageRevealState.left), hasRightState: Boolean(pageRevealState.right) }); } 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) { if (pageRevealFreezeAt === null) pageRevealFreezeAt = now; return; } if (pageRevealFreezeAt !== null) { const frozenMs = Math.max(0, now - pageRevealFreezeAt); ['left', 'right'].forEach((side) => { const state = pageRevealState[side]; if (!state || state.startedAt == null) return; state.startedAt += frozenMs; state.lastRevealFrameAt = now; }); activeRevealBlockStarts.forEach((value, blockId) => { if (Number.isFinite(Number(value))) { activeRevealBlockStarts.set(blockId, Number(value) + frozenMs); } }); pageRevealFreezeAt = null; } ['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 (materials.flipPageSurface.userData.sourceRevealSide === side) { syncFlipRevealShaderFromSource(side, materials.flipPageSurface); } if (materials.flipPageBackSurface.userData.sourceRevealSide === side) { syncFlipRevealShaderFromSource(side, materials.flipPageBackSurface); } if (progress < 1) return; clearPageReveal(side, 'duration-complete'); document.dispatchEvent(new CustomEvent('webgl-book:reveal-committed', { detail: { side, blockIds: state.blockIds, pageFlipAfterReveal: state.pageFlipAfterReveal === true } })); }); } 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 }); const boundTexture = pageTextureStore?.bindVisibleTextureSource?.(side, sourceCanvas) || null; if (!boundTexture) { texture.image = nextCanvas; 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 = options.prewarm || options.flipPlan?.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; flip.deferRevealSides = Array.isArray(options.deferRevealSides) ? options.deferRevealSides : null; if (!prepareStaticPageForFlip(flip, options.prewarm || null)) { return false; } activeFlips.push(flip); setPageFlipActiveFlag(); document.dispatchEvent(new CustomEvent('webgl-book:page-flip-started', { 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 } })); 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 = options.prewarm || options.flipPlan?.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; firstFlip.targetSpread = Number.isFinite(Number(options.targetSpread)) ? Math.max(0, Math.round(Number(options.targetSpread))) : null; 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(); document.dispatchEvent(new CustomEvent('webgl-book:page-flip-started', { detail: { direction: firstFlip.direction, sourceSide: firstFlip.sourcePageSide || (firstFlip.direction > 0 ? 'right' : 'left'), targetSpread: Number.isFinite(Number(firstFlip.targetSpread)) ? Math.max(0, Math.round(Number(firstFlip.targetSpread))) : null, fast: true } })); syncBookControls(); updateActiveFlips(startTime); return true; } function createPageFlip(direction, startTime, duration) { const sourceSide = direction > 0 ? 1 : -1; const sourcePageSide = direction > 0 ? 'right' : 'left'; // Use the raw page-cap line (as the working prototype / pre-ef358c5 lab did). Each // line's points[0] === its spine-arc anchor (spineCurvePoint(t)), so the flip sheet // hinges at the spine. Rewriting the line to the "visible page width" moved the pivot // off the spine arc and folded the inner spine-wall climb into a crease at the spine. 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 sourceSide = flip.direction > 0 ? 'right' : 'left'; const sourceTexture = resolveCurrentFlipSourceTexture(sourceSide); const sourcePageMeta = getPaginationPageMeta(spreadPageIndices(bookPaginationState.spreadIndex)[sourceSide]) || currentPageMeta?.[sourceSide] || null; 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 targetBackSide = flip.direction > 0 ? 'left' : 'right'; const targetBackPageIndex = targetPages[targetBackSide]; const targetBackPageMeta = getPaginationPageMeta(targetBackPageIndex) || makeBlankPageMeta(targetBackPageIndex); const prewarmedBackTexture = flip.direction > 0 ? prewarm?.next?.left : prewarm?.next?.right; const backTexture = resolveFlipBackTexture(targetBackPageMeta, prewarmedBackTexture); const requiresWrittenTexture = targetBackPageMeta.kind !== 'blank' && targetBackPageIndex <= Math.max(2, Number(bookPaginationState.writtenPageLimit || 0)); if (!sourceTexture || (!backTexture && requiresWrittenTexture)) { pageTextureStore?.recordProblem?.({ type: !sourceTexture ? 'flip-source-texture-missing' : 'flip-back-texture-missing', sourceSide, sourcePageIndex: sourcePageMeta?.pageIndex ?? null, targetBackPageIndex, targetBackKind: targetBackPageMeta.kind, targetSpread, direction: flip.direction, prewarmedCurrent: Boolean(prewarm?.current), prewarmedNext: Boolean(prewarm?.next) }); return false; } // If the page the flip lands on will be revealed right after (a block reveals on // that side), do not show its full text on the turning page's back face — that // flashes the not-yet-revealed content. Show blank during the turn; the masked // reveal lands on the static page once the flip finishes. const backDeferred = Array.isArray(flip.deferRevealSides) && flip.deferRevealSides.includes(targetBackSide); materials.flipPageSurface.map = sourceTexture; materials.flipPageBackSurface.map = backDeferred ? getBlankPageTexture() : (backTexture || getBlankPageTexture()); materials.flipPageSurface.userData.sourceRevealSide = revealStateMatchesPage(sourceSide, sourcePageMeta) ? sourceSide : null; materials.flipPageBackSurface.userData.sourceRevealSide = backDeferred ? null : (revealStateMatchesPage(targetBackSide, targetBackPageMeta) ? targetBackSide : null); 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; syncFlipRevealShaderFromSource(sourceSide, materials.flipPageSurface); syncFlipRevealShaderFromSource(targetBackSide, materials.flipPageBackSurface); flip.sourceTexture = sourceTexture; flip.sourcePageMeta = sourcePageMeta ? { ...sourcePageMeta } : null; flip.backTexture = backTexture || getBlankPageTexture(); flip.backPageMeta = targetBackPageMeta ? { ...targetBackPageMeta } : null; flip.targetBackPageIndex = targetBackPageIndex; flip.sourcePageSide = sourceSide; lastFlipTexturePreflight = { direction: flip.direction, sourceSide, sourcePageIndex: sourcePageMeta?.pageIndex ?? null, sourceKind: sourcePageMeta?.kind || 'content', targetSpread, targetBackSide, targetBackPageIndex, targetBackKind: targetBackPageMeta.kind, hasSourceTexture: Boolean(sourceTexture), hasBackTexture: Boolean(backTexture || getBlankPageTexture()), sourceTextureMatchesBackTexture: sourceTexture === (backTexture || getBlankPageTexture()) }; // The page lifts from the source side, uncovering the target spread's same-side page // beneath it. Show that target page now (hidden under the lifting page at t=0, then // revealed as it turns away) instead of a blank that pops in at the end. If that side // has a pending reveal (playback), keep it blank so activate lands the masked reveal. const revealedSide = sourceSide; const revealedMaterial = revealedSide === 'left' ? materials.leftPage : materials.rightPage; const revealedDeferred = Array.isArray(flip.deferRevealSides) && flip.deferRevealSides.includes(revealedSide); const revealedMeta = getPaginationPageMeta(targetPages[revealedSide]) || makeBlankPageMeta(targetPages[revealedSide]); const revealedTexture = (revealedDeferred || revealedMeta.kind === 'blank') ? getBlankPageTexture() : (pageTextureStore?.getResidentTextureForMeta?.(revealedMeta) || getBlankPageTexture()); clearPageReveal(revealedSide, 'page-flip-start', { preserveBaseTexture: true }); if (revealedTexture && revealedMaterial.map !== revealedTexture) { revealedMaterial.map = revealedTexture; revealedMaterial.needsUpdate = true; } markPageTextureTiming('flipTexturePreflight:ready', { ...lastFlipTexturePreflight, usedResidentBackTexture: Boolean(backTexture && backTexture !== getBlankPageTexture()) }); return true; } function resolveCurrentFlipSourceTexture(side) { // Derive the source page meta from the actually-visible spread. currentPageMeta is // only refreshed by the activate pipeline, so it is stale after manual navigation — // using it here resolved the wrong source texture for the next flip. const visiblePageIndex = spreadPageIndices(bookPaginationState.spreadIndex)[side]; const pageMeta = getPaginationPageMeta(visiblePageIndex) || currentPageMeta?.[side] || null; if (pageMeta?.kind === 'blank') return getBlankPageTexture(); const material = side === 'left' ? materials.leftPage : materials.rightPage; if (revealStateMatchesPage(side, pageMeta)) return material?.map || null; const resident = pageTextureStore?.getResidentTextureForMeta?.(pageMeta); if (resident) return resident; return material?.map || null; } function resolveFlipBackTexture(pageMeta = null, prewarmedTexture = null) { if (pageMeta?.kind === 'blank') return getBlankPageTexture(); if (prewarmedTexture) return prewarmedTexture; return pageTextureStore?.getResidentTextureForMeta?.(pageMeta); } function canPageFlip(direction) { if (!currentProceduralBookModel) return false; const currentSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0))); if (direction > 0) return currentSpread < getMaxNavigableSpread(); return currentSpread > 0; } 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 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; // Debug/isolation hook: when window.__debugFlipFreezeT is a finite number in [0,1], // hold every active flip at that progress so a single frame can be inspected. const freezeT = Number(window.__debugFlipFreezeT); const frozen = Number.isFinite(freezeT); const completed = []; activeFlips.forEach((flip) => { const elapsed = frozen ? THREE.MathUtils.clamp(freezeT, 0, 1) : (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; if (targetSpread !== null && !hasActivePageReveal()) { // Skip the revealing side(s): the timeline's activate lands the masked // reveal for them right after the flip. Showing the full resident texture // here would flash the not-yet-revealed block. applyResidentSpreadTextures(targetSpread, 'page-flip-near-end', { skipSides: flip.deferRevealSides }); } 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 hasActivePageReveal() { return ['left', 'right'].some((side) => { const state = pageRevealState[side]; if (!state) return false; if (state.startedAt != null) return true; return Array.isArray(state.blockIds) && state.blockIds.length > 0; }); } function applyResidentSpreadTextures(spreadIndex, reason = 'resident-spread', options = {}) { const skipSides = Array.isArray(options.skipSides) ? options.skipSides : []; const pageIndices = spreadPageIndices(spreadIndex); ['left', 'right'].forEach((side) => { if (skipSides.includes(side)) return; const pageIndex = pageIndices[side]; const pageMeta = getPaginationPageMeta(pageIndex) || makeBlankPageMeta(pageIndex); const texture = pageMeta.kind === 'blank' ? getBlankPageTexture() : pageTextureStore?.getResidentTextureForMeta?.(pageMeta); if (!texture) { pageTextureStore?.recordProblem?.({ type: 'resident-spread-texture-missing', reason, side, spreadIndex, pageIndex, pageKind: pageMeta.kind }); return; } const material = side === 'left' ? materials.leftPage : materials.rightPage; const activeRevealForPage = revealStateMatchesPage(side, pageMeta); if (!activeRevealForPage) clearPageReveal(side, reason); if (material.map !== texture) { material.map = texture; material.needsUpdate = true; } }); markStaticSceneBuffersDirty(); markPageTextureTiming('residentSpreadTextures:applied', { reason, spreadIndex }); } function buildFlippingPageSurface(sourceLine, destinationLine, direction, t, pageOffset = 0) { const widthSegments = sourceLine.points.length - 1; const depthSegments = 18; const zFront = currentProceduralBookModel.pageDepth * 0.5; const zBack = -currentProceduralBookModel.pageDepth * 0.5; if (t <= 0) return createRestingPageSurface(sourceLine.points, depthSegments, zFront, zBack); if (t >= 1) return createRestingPageSurface(destinationLine.points, depthSegments, zFront, zBack); const anchor = { x: THREE.MathUtils.lerp(sourceLine.anchor.x, destinationLine.anchor.x, t), y: THREE.MathUtils.lerp(sourceLine.anchor.y, destinationLine.anchor.y, t) }; const sourceSide = direction > 0 ? 1 : -1; const startAngle = sourceSide > 0 ? 0 : Math.PI; const baseAngle = startAngle + direction * Math.PI * t; const lift = Math.sin(Math.PI * t); const curlStrength = direction * 0.48 * lift; const widthDistances = cumulativeLineLengths(sourceLine.points); const surface = []; for (let widthIndex = 0; widthIndex <= widthSegments; widthIndex += 1) { const u = widthIndex / widthSegments; const radius = widthDistances[widthIndex]; const row = []; for (let depthIndex = 0; depthIndex <= depthSegments; depthIndex += 1) { const v = depthIndex / depthSegments; const z = THREE.MathUtils.lerp(zFront, zBack, v); const depthWave = (v - 0.5) * 0.22 * lift * (0.15 + u * 0.85); const curl = curlStrength * Math.sin(Math.PI * u) + direction * depthWave; const angle = baseAngle + curl; const stackPoint = interpolatePagePoint(sourceLine.points, destinationLine.points, widthIndex, t); const flyingX = anchor.x + Math.cos(angle) * radius; const relaxedY = THREE.MathUtils.lerp(stackPoint.y, anchor.y + Math.sin(angle) * radius, lift); const point = { x: THREE.MathUtils.lerp(stackPoint.x, flyingX, lift), y: relaxedY + pageOffset + 0.055 * lift * Math.sin(Math.PI * u), z }; keepFlippingSurfacePointAboveStacks(point, lift); row.push(point); } surface.push(row); } return surface; } function cumulativeLineLengths(points) { const lengths = [0]; for (let index = 1; index < points.length; index += 1) { const previous = points[index - 1]; const point = points[index]; lengths[index] = lengths[index - 1] + Math.hypot(point.x - previous.x, point.y - previous.y); } return lengths; } function createRestingPageSurface(points, depthSegments, zFront, zBack) { return points.map((point) => { const row = []; for (let depthIndex = 0; depthIndex <= depthSegments; depthIndex += 1) { row.push({ x: point.x, y: point.y, z: THREE.MathUtils.lerp(zFront, zBack, depthIndex / depthSegments) }); } return row; }); } function interpolatePagePoint(sourcePoints, destinationPoints, index, t) { const source = sourcePoints[index]; const destination = destinationPoints[index]; return { x: THREE.MathUtils.lerp(source.x, destination.x, t), y: THREE.MathUtils.lerp(source.y, destination.y, t) }; } function keepFlippingSurfacePointAboveStacks(point, lift) { const envelopeY = stackEnvelopeYAtX(point.x); if (envelopeY === null) return; const clearance = 0.016 + lift * 0.045; point.y = Math.max(point.y, envelopeY + clearance); } function stackEnvelopeYAtX(x) { let envelope = null; currentProceduralBookModel.lines.forEach((line) => { const y = lineYAtX(line.points, x); if (y === null) return; envelope = envelope === null ? y : Math.max(envelope, y); }); return envelope; } function lineYAtX(points, x) { let y = null; for (let index = 0; index < points.length - 1; index += 1) { const a = points[index]; const b = points[index + 1]; const minX = Math.min(a.x, b.x) - 0.00001; const maxX = Math.max(a.x, b.x) + 0.00001; if (x < minX || x > maxX) continue; const span = b.x - a.x; const segmentY = Math.abs(span) < 0.00001 ? Math.max(a.y, b.y) : THREE.MathUtils.lerp(a.y, b.y, (x - a.x) / span); y = y === null ? segmentY : Math.max(y, segmentY); } return y; } function setActivePageGeometry(flip, surface) { const widthRatios = flipWidthRatios(flip.sourceLine?.points); if (!flip.mesh) { const geometry = createFlippingPageGeometry(surface, flip.direction, widthRatios); 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.direction, widthRatios); flip.mesh.geometry.dispose(); flip.mesh.geometry = geometry; } } // Texture U coordinates must follow physical page width (the spline uses short // segments near the spine and long segments near the fore-edge), not the uniform // vertex index, otherwise the flip texture is horizontally compressed relative to // the static stack cap. function flipWidthRatios(points) { if (!Array.isArray(points) || points.length < 2) return null; const lengths = cumulativeLineLengths(points); const total = lengths[lengths.length - 1]; if (!(total > 0)) return null; return lengths.map(length => length / total); } function createFlippingPageGeometry(surface, direction = 1, widthRatios = null) { 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 sourceSide = direction > 0 ? 1 : -1; const targetSide = -sourceSide; const topPageSide = direction > 0 ? targetSide : sourceSide; const bottomPageSide = direction > 0 ? sourceSide : targetSide; // The page's width index runs spine->right for forward flips and spine->left for // backward flips, which inverts the computed face normals. Assign the source // texture to the face that actually points at the camera at the start of the turn // so the lifting page shows the page it came from (not the page it lands on). const topMaterialIndex = direction > 0 ? 1 : 0; const bottomMaterialIndex = direction > 0 ? 0 : 1; const push = (point, yOffset, uv) => { const index = positions.length / 3; positions.push(point.x, point.y + yOffset, point.z); uvs.push(uv.x, uv.y); return index; }; surface.forEach((rowPoints, widthIndex) => { const topRow = []; const bottomRow = []; const u = Array.isArray(widthRatios) && widthRatios.length === surface.length ? widthRatios[widthIndex] : (widthSegments <= 0 ? 0 : widthIndex / widthSegments); rowPoints.forEach((point, depthIndex) => { const v = depthSegments <= 0 ? 0 : depthIndex / depthSegments; topRow.push(push(point, pageThickness, pageUvForSide(topPageSide, u, v))); bottomRow.push(push(point, 0, pageUvForSide(bottomPageSide, u, v))); }); topGrid.push(topRow); bottomGrid.push(bottomRow); }); for (let index = 0; index < widthSegments; index += 1) { for (let zIndex = 0; zIndex < depthSegments; zIndex += 1) { const a = topGrid[index][zIndex]; const b = topGrid[index + 1][zIndex]; const c = topGrid[index][zIndex + 1]; const d = topGrid[index + 1][zIndex + 1]; const bottomA = bottomGrid[index][zIndex]; const bottomB = bottomGrid[index + 1][zIndex]; const bottomC = bottomGrid[index][zIndex + 1]; const bottomD = bottomGrid[index + 1][zIndex + 1]; 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, topMaterialIndex); geometry.addGroup(topIndices.length, bottomIndices.length, bottomMaterialIndex); 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 pageUvForSide(side, u, v) { const inset = THREE.MathUtils.clamp(Number(PROCEDURAL_BOOK.PAGE_TEXTURE_FORE_EDGE_INSET_RATIO || 0), 0, 0.35); const pageU = THREE.MathUtils.clamp(u / Math.max(0.0001, 1 - inset), 0, 1); return { x: side < 0 ? 1 - pageU : pageU, y: v }; } 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))) }; maxVisitedPagePosition = Math.max(maxVisitedPagePosition, getCurrentPagePosition()); syncReadingProgressToCurrentPage({ rebuild: 'defer', reason: 'page-flip-finished' }); } 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; materials.flipPageSurface.userData.sourceRevealSide = null; materials.flipPageBackSurface.userData.sourceRevealSide = 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 < minRenderFrameIntervalMs) { 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; const updateStartedAt = performance.now(); 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; const flipStartedAt = performance.now(); updateActiveFlips(performance.now()); lastFrameTiming.flips = performance.now() - flipStartedAt; if (hadActiveFlips) markStaticSceneBuffersDirty(); const revealStartedAt = performance.now(); updatePageRevealAnimations(now); lastFrameTiming.reveal = performance.now() - revealStartedAt; updateCandleShadowUniforms(); lastFrameTiming.update = performance.now() - updateStartedAt; renderedFrameCount += 1; const refreshStaticSceneBuffers = staticSceneBuffersDirty || activeFlips.length > 0; const shadowStartedAt = performance.now(); const forceDynamicBufferRefresh = staticSceneBuffersDirty && activeFlips.length === 0; const newestFlipAge = activeFlips.length ? Math.min(...activeFlips.map(flip => Math.max(0, now - Number(flip.startTime || now)))) : Infinity; const deferDynamicBuffersForFlipStart = activeFlips.length > 0 && newestFlipAge < flipDynamicBufferGraceMs; const shadowRefreshDue = !deferDynamicBuffersForFlipStart && ( forceDynamicBufferRefresh || now - lastBookShadowRefreshAt >= dynamicBufferRefreshIntervalMs ); const reflectionRefreshDue = !deferDynamicBuffersForFlipStart && ( forceDynamicBufferRefresh || now - lastTableReflectionRefreshAt >= dynamicBufferRefreshIntervalMs ); const bothHeavyPassesDue = shadowRefreshDue && reflectionRefreshDue && !forceDynamicBufferRefresh; const refreshShadowsThisFrame = shadowRefreshDue && ( !bothHeavyPassesDue || lastBookShadowRefreshAt <= lastTableReflectionRefreshAt ); const refreshReflectionThisFrame = reflectionRefreshDue && ( !bothHeavyPassesDue || !refreshShadowsThisFrame ); if (refreshShadowsThisFrame) { updateBookShadowMaps(); lastBookShadowRefreshAt = now; } lastFrameTiming.shadows = performance.now() - shadowStartedAt; const reflectionStartedAt = performance.now(); if (refreshReflectionThisFrame) { updateTableReflection(); lastTableReflectionRefreshAt = now; } 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.update + lastFrameTiming.shadows + lastFrameTiming.reflection + lastFrameTiming.render; if (frameElapsedMs > targetFrameDurationMs * 1.75 || lastFrameTiming.total > targetFrameDurationMs * 1.25) { slowFrameLog.push({ at: Math.round(now), frameElapsedMs: Math.round(frameElapsedMs * 100) / 100, activeFlips: activeFlips.length, revealActive: Boolean(pageRevealState.left || pageRevealState.right), timings: { update: Math.round(lastFrameTiming.update * 100) / 100, flips: Math.round(lastFrameTiming.flips * 100) / 100, reveal: Math.round(lastFrameTiming.reveal * 100) / 100, shadows: Math.round(lastFrameTiming.shadows * 100) / 100, reflection: Math.round(lastFrameTiming.reflection * 100) / 100, render: Math.round(lastFrameTiming.render * 100) / 100, total: Math.round(lastFrameTiming.total * 100) / 100 } }); while (slowFrameLog.length > 80) slowFrameLog.shift(); document.documentElement.dataset.webglSlowFrames = JSON.stringify(slowFrameLog.slice(-20)); } 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); }