b0175b7cdc
Two robustness gaps from the worker migration, both raised in review: - The raster worker had no failure recovery: a thrown createImageBitmap/font error or a dropped message would leave the draw promise pending forever, stalling the serialized draw chain and hanging prepare/playback. Added worker.onerror and a per-job timeout; both settle the in-flight draw to a logged miss (texture-worker-error / -timeout) so the pipeline degrades to last-good per the spec instead of hanging. A single settleRasterization path clears the timer and resolves. - prepareSpreadTextureRecordsForFlip() called drawSpread() without awaiting it. That was safe when drawSpread was synchronous, but now that it is async (worker) the flip could race ahead of the worker draw and miss the resident texture. prewarmFlipTextures now awaits both spread draws before the resident-texture lookup. Suite 168 (added assertions for worker error/timeout recovery and the awaited prewarm). Normal draw path is behaviorally unchanged from the verified worker commit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4804 lines
212 KiB
JavaScript
4804 lines
212 KiB
JavaScript
import * as THREE from 'https://esm.sh/three@0.165.0';
|
||
import { EffectComposer } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/EffectComposer.js';
|
||
import { RenderPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/RenderPass.js';
|
||
import { SSAOPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/SSAOPass.js';
|
||
import { SMAAPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/SMAAPass.js';
|
||
import { OutputPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/OutputPass.js';
|
||
import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260610-book-timeline-l';
|
||
|
||
const canvas = document.getElementById('scene');
|
||
// The canvas inherits the document-level process-state cursor (awaiting input / server /
|
||
// background / animation) so the 3D scene communicates game state like the overlay does.
|
||
// A grab cursor is shown only transiently while actively right-drag-rotating the camera.
|
||
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;
|
||
// Render scale. The book is tilted, so page glyphs are minified along the tilt and need
|
||
// supersampling to stay crisp — 2× is the established sharp baseline. This is deliberately
|
||
// NOT reduced for performance (that blurs text); 60fps comes from the reflection/shadow
|
||
// passes instead, whose cost is independent of this scale.
|
||
const renderSupersample = 2;
|
||
const appRenderPixelRatio = Math.min((window.devicePixelRatio || 1) * renderSupersample, 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;
|
||
// Shadows and the table reflection only need full-rate updates while the book geometry is
|
||
// moving (a page flip). When the geometry is static — idle, or a text reveal where only the
|
||
// page texture mask animates — the soft shadows/reflection refresh far less often, so those
|
||
// frames are just the cheap scene render and hold 60fps. Candle flicker is the only thing
|
||
// changing them then, which 8Hz captures imperceptibly.
|
||
const staticGeometryBufferRefreshIntervalMs = 1000 / 8;
|
||
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 || {});
|
||
});
|
||
// New game / history restore: drop block-id-keyed reveal state so a reused block id does
|
||
// not inherit the previous run's reveal start time (which would skip the animation).
|
||
document.addEventListener('story:client-reset', () => {
|
||
activeRevealBlockStarts.clear();
|
||
pendingRevealStartBlockIds.clear();
|
||
pageRevealFreezeAt = null;
|
||
clearPageReveal('left', 'client-reset');
|
||
clearPageReveal('right', 'client-reset');
|
||
});
|
||
// 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 <common>',
|
||
`#include <common>
|
||
varying vec3 vBookReceiverWorldPosition;
|
||
varying vec3 vBookReceiverWorldNormal;
|
||
${isSpineCloth || isHardcoverPaper || isHeadband ? 'varying vec2 vBookSurfaceUv;' : ''}`
|
||
)
|
||
.replace(
|
||
'#include <defaultnormal_vertex>',
|
||
`#include <defaultnormal_vertex>
|
||
vBookReceiverWorldNormal = normalize(mat3(modelMatrix) * objectNormal);`
|
||
)
|
||
.replace(
|
||
'#include <begin_vertex>',
|
||
`${isSpineCloth || isHardcoverPaper || isHeadband ? 'vBookSurfaceUv = uv;' : ''}
|
||
#include <begin_vertex>`
|
||
)
|
||
.replace(
|
||
'#include <project_vertex>',
|
||
'vBookReceiverWorldPosition = (modelMatrix * vec4(transformed, 1.0)).xyz;\n#include <project_vertex>'
|
||
);
|
||
|
||
shader.fragmentShader = shader.fragmentShader
|
||
.replace(
|
||
'#include <common>',
|
||
`#include <common>
|
||
uniform sampler2D bookShadowMaps[3];
|
||
uniform mat4 bookShadowMatrices[3];
|
||
uniform vec2 bookShadowMapTexelSize;
|
||
uniform float bookShadowReceiverStrength;
|
||
uniform float bookTableTopY;
|
||
${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 <opaque_fragment>',
|
||
`${isSpineCloth ? 'outgoingLight = spineClothLight(vBookSurfaceUv, outgoingLight);' : ''}
|
||
${isHardcoverPaper ? 'outgoingLight = hardcoverPaperLight(vBookSurfaceUv, outgoingLight);' : ''}
|
||
${isHeadband ? 'outgoingLight = headbandCreviceLight(vBookSurfaceUv, outgoingLight);' : ''}
|
||
float bookReceiverShadow = bookReceiverShadowField(vBookReceiverWorldPosition) * bookShadowReceiverStrength;
|
||
outgoingLight *= mix(vec3(1.0), ${isHeadband ? 'vec3(0.16, 0.095, 0.055)' : 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 <opaque_fragment>`
|
||
);
|
||
if (pageReveal) {
|
||
shader.fragmentShader = shader.fragmentShader.replace(
|
||
'#include <map_fragment>',
|
||
`#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 <common>',
|
||
'#include <common>\nvarying float vWaxLocalY;\nvarying vec3 vWaxWorldPosition;\nvarying vec3 vWaxWorldNormal;'
|
||
)
|
||
.replace(
|
||
'#include <begin_vertex>',
|
||
'#include <begin_vertex>\nvWaxLocalY = position.y;'
|
||
)
|
||
.replace(
|
||
'#include <defaultnormal_vertex>',
|
||
'#include <defaultnormal_vertex>\nvWaxWorldNormal = normalize(mat3(modelMatrix) * objectNormal);'
|
||
)
|
||
.replace(
|
||
'#include <project_vertex>',
|
||
'vWaxWorldPosition = (modelMatrix * vec4(transformed, 1.0)).xyz;\n#include <project_vertex>'
|
||
);
|
||
shader.fragmentShader = shader.fragmentShader
|
||
.replace(
|
||
'#include <common>',
|
||
`#include <common>
|
||
uniform float waxHeight;
|
||
uniform vec3 waxFlameWorldPosition;
|
||
uniform vec3 waxBodyWorldPosition;
|
||
uniform float waxLightPower;
|
||
varying float vWaxLocalY;
|
||
varying vec3 vWaxWorldPosition;
|
||
varying vec3 vWaxWorldNormal;`
|
||
)
|
||
.replace(
|
||
'#include <opaque_fragment>',
|
||
`float waxTop = smoothstep(waxHeight * 0.08, waxHeight * 0.5, vWaxLocalY);
|
||
float waxRim = pow(1.0 - abs(dot(normalize(normal), normalize(geometryViewDir))), 2.2);
|
||
float waxCore = smoothstep(-waxHeight * 0.45, waxHeight * 0.3, vWaxLocalY);
|
||
float waxFlameCup = smoothstep(waxHeight * 0.28, waxHeight * 0.52, vWaxLocalY);
|
||
vec3 waxToFlame = waxFlameWorldPosition - vWaxWorldPosition;
|
||
float waxFlameDistance = length(waxToFlame);
|
||
vec3 waxLightDir = normalize(waxToFlame);
|
||
vec3 waxWorldNormal = normalize(vWaxWorldNormal);
|
||
float waxNearFlame = 1.0 - smoothstep(0.06, 0.58, waxFlameDistance);
|
||
float waxUpperBody = smoothstep(waxBodyWorldPosition.y + waxHeight * 0.38, waxBodyWorldPosition.y + waxHeight * 0.92, vWaxWorldPosition.y);
|
||
float waxForwardScatter = pow(max(dot(waxWorldNormal, waxLightDir), 0.0), 0.62);
|
||
float waxBackScatter = pow(max(dot(-waxWorldNormal, waxLightDir), 0.0), 1.5);
|
||
float waxSideGlow = pow(max(1.0 - abs(dot(waxWorldNormal, waxLightDir)), 0.0), 1.15);
|
||
float waxSubsurface = (waxNearFlame * (0.56 + waxForwardScatter * 0.42) + waxBackScatter * 0.25 + waxSideGlow * 0.18) * waxUpperBody * waxLightPower;
|
||
float waxCavityGlow = waxFlameCup * waxNearFlame * waxLightPower * 0.75;
|
||
vec3 waxScatter = vec3(1.0, 0.48, 0.19) * (waxTop * 0.11 + waxRim * waxCore * 0.09 + waxFlameCup * 0.08 + waxSubsurface * 0.46 + waxCavityGlow * 0.36);
|
||
outgoingLight += waxScatter;
|
||
#include <opaque_fragment>`
|
||
);
|
||
};
|
||
|
||
return material;
|
||
}
|
||
|
||
function configureTableShader(material) {
|
||
material.customProgramCacheKey = () => 'book-lab-table-planar-environment-reflection-v7';
|
||
material.onBeforeCompile = (shader) => {
|
||
tableShader = shader;
|
||
shader.uniforms.roomReflectionMap = { value: tableRoomReflectionTexture };
|
||
shader.uniforms.sceneReflectionMap = { value: tableReflectionTarget.texture };
|
||
shader.uniforms.sceneReflectionMatrix = { value: tableReflectionMatrix };
|
||
shader.uniforms.tableDustMap = { value: tableDustTexture };
|
||
shader.uniforms.tableGreaseMap = { value: tableGreaseTexture };
|
||
shader.uniforms.candleBodyPositions = {
|
||
value: [new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3()]
|
||
};
|
||
shader.uniforms.candleFlamePositions = {
|
||
value: [new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3()]
|
||
};
|
||
shader.uniforms.candleBodyData = {
|
||
value: [new THREE.Vector2(), new THREE.Vector2(), new THREE.Vector2()]
|
||
};
|
||
shader.uniforms.bookShadowMaps = { value: bookShadowTargets.map((target) => target.texture) };
|
||
shader.uniforms.bookShadowMatrices = { value: bookShadowMatrices };
|
||
shader.uniforms.bookShadowMapTexelSize = { value: new THREE.Vector2(1 / bookShadowMapSize, 1 / bookShadowMapSize) };
|
||
shader.uniforms.tableDebugMode = { value: tableDebugMode };
|
||
|
||
shader.vertexShader = shader.vertexShader
|
||
.replace(
|
||
'#include <common>',
|
||
'#include <common>\nuniform mat4 sceneReflectionMatrix;\nvarying vec3 vTableWorldPosition;\nvarying vec4 vSceneReflectionCoord;'
|
||
)
|
||
.replace(
|
||
'#include <project_vertex>',
|
||
'vec4 tableWorldPosition = modelMatrix * vec4(transformed, 1.0);\nvTableWorldPosition = tableWorldPosition.xyz;\nvSceneReflectionCoord = sceneReflectionMatrix * tableWorldPosition;\n#include <project_vertex>'
|
||
);
|
||
|
||
shader.fragmentShader = shader.fragmentShader
|
||
.replace(
|
||
'#include <common>',
|
||
`#include <common>
|
||
uniform sampler2D roomReflectionMap;
|
||
uniform sampler2D sceneReflectionMap;
|
||
uniform sampler2D tableDustMap;
|
||
uniform sampler2D tableGreaseMap;
|
||
uniform mat4 sceneReflectionMatrix;
|
||
uniform vec3 candleBodyPositions[3];
|
||
uniform vec3 candleFlamePositions[3];
|
||
uniform vec2 candleBodyData[3];
|
||
uniform sampler2D bookShadowMaps[3];
|
||
uniform mat4 bookShadowMatrices[3];
|
||
uniform vec2 bookShadowMapTexelSize;
|
||
uniform int tableDebugMode;
|
||
varying vec3 vTableWorldPosition;
|
||
varying vec4 vSceneReflectionCoord;
|
||
|
||
vec2 tableDustUvFromWorld(vec3 worldPosition) {
|
||
return clamp(vec2(worldPosition.x / 9.8 + 0.5, 0.5 - worldPosition.z / 6.6), vec2(0.0), vec2(1.0));
|
||
}
|
||
|
||
float tableTopMaskFromWorld(vec3 worldPosition) {
|
||
return smoothstep(-0.095, -0.025, worldPosition.y);
|
||
}
|
||
|
||
float tableDustFromWorld(vec3 worldPosition) {
|
||
return texture2D(tableDustMap, tableDustUvFromWorld(worldPosition)).r * tableTopMaskFromWorld(worldPosition);
|
||
}
|
||
|
||
float tableGreaseFromWorld(vec3 worldPosition) {
|
||
return texture2D(tableGreaseMap, tableDustUvFromWorld(worldPosition)).r * tableTopMaskFromWorld(worldPosition);
|
||
}
|
||
|
||
vec3 rotateRoomReflection(vec3 dir) {
|
||
const float yaw = 0.42;
|
||
float s = sin(yaw);
|
||
float c = cos(yaw);
|
||
return normalize(vec3(c * dir.x - s * dir.z, dir.y, s * dir.x + c * dir.z));
|
||
}
|
||
|
||
vec3 sampleRoomReflection(vec3 dir) {
|
||
dir = rotateRoomReflection(normalize(dir));
|
||
float u = 0.5 + atan(dir.z, dir.x) / 6.28318530718;
|
||
float v = 0.5 + asin(clamp(dir.y, -1.0, 1.0)) / 3.14159265359;
|
||
return texture2D(roomReflectionMap, vec2(u, v)).rgb;
|
||
}
|
||
|
||
vec3 sampleRoughRoomReflection(vec3 dir) {
|
||
vec3 tangent = normalize(cross(vec3(0.0, 1.0, 0.0), dir));
|
||
if (length(tangent) < 0.01) tangent = vec3(1.0, 0.0, 0.0);
|
||
vec3 bitangent = normalize(cross(dir, tangent));
|
||
return sampleRoomReflection(dir) * 0.42 +
|
||
sampleRoomReflection(dir + tangent * 0.035) * 0.18 +
|
||
sampleRoomReflection(dir - tangent * 0.035) * 0.18 +
|
||
sampleRoomReflection(dir + bitangent * 0.026) * 0.11 +
|
||
sampleRoomReflection(dir - bitangent * 0.026) * 0.11;
|
||
}
|
||
|
||
float candleBodyOcclusion(vec3 point, vec3 flame, vec3 body, vec2 bodyData, float selfLight) {
|
||
vec3 segment = point - flame;
|
||
vec2 segmentXZ = segment.xz;
|
||
vec2 flameToBody = flame.xz - body.xz;
|
||
float radius = bodyData.x;
|
||
float a = max(dot(segmentXZ, segmentXZ), 0.000001);
|
||
float b = 2.0 * dot(flameToBody, segmentXZ);
|
||
float c = dot(flameToBody, flameToBody) - radius * radius;
|
||
float discriminant = b * b - 4.0 * a * c;
|
||
float nearestT = clamp(-dot(flameToBody, segmentXZ) / a, 0.0, 1.0);
|
||
vec2 nearestXZ = flameToBody + segmentXZ * nearestT;
|
||
float nearestDistance = length(nearestXZ);
|
||
float hitT = nearestT;
|
||
float cylinderHit = 0.0;
|
||
if (discriminant > 0.0) {
|
||
float sqrtDisc = sqrt(discriminant);
|
||
float t0 = (-b - sqrtDisc) / (2.0 * a);
|
||
float t1 = (-b + sqrtDisc) / (2.0 * a);
|
||
float validT0 = step(0.0, t0) * step(t0, 1.0);
|
||
float validT1 = step(0.0, t1) * step(t1, 1.0);
|
||
hitT = mix(hitT, t0, validT0);
|
||
hitT = mix(hitT, t1, (1.0 - validT0) * validT1);
|
||
cylinderHit = max(validT0, validT1);
|
||
}
|
||
float closestY = flame.y + segment.y * hitT;
|
||
float bodyTop = body.y + bodyData.y;
|
||
float vertical = smoothstep(body.y - 0.045, body.y + 0.08, closestY) *
|
||
(1.0 - smoothstep(bodyTop - 0.08, bodyTop + 0.045, closestY));
|
||
float segmentLength = length(segment);
|
||
float penumbraWidth = radius * (0.45 + segmentLength * 0.12);
|
||
float exactHit = cylinderHit;
|
||
float softHit = 1.0 - smoothstep(radius, radius + penumbraWidth, nearestDistance);
|
||
float selfShadowLimiter = mix(1.0, 0.06, selfLight);
|
||
float waxExitHeight = smoothstep(body.y, bodyTop, closestY);
|
||
float waxTransmission = 0.48 + 0.34 * waxExitHeight;
|
||
float bodyOpacity = 1.0 - waxTransmission * mix(0.62, 0.86, selfLight);
|
||
return clamp(max(exactHit, softHit * 0.72) * vertical * selfShadowLimiter * bodyOpacity, 0.0, 0.42);
|
||
}
|
||
|
||
float candleProjectedShadowField(vec3 point) {
|
||
float projectedShadow = 0.0;
|
||
for (int bodyIndex = 0; bodyIndex < 3; bodyIndex++) {
|
||
for (int flameIndex = 0; flameIndex < 3; flameIndex++) {
|
||
float selfLight = bodyIndex == flameIndex ? 1.0 : 0.0;
|
||
projectedShadow = max(projectedShadow, candleBodyOcclusion(point, candleFlamePositions[flameIndex], candleBodyPositions[bodyIndex], candleBodyData[bodyIndex], selfLight));
|
||
}
|
||
}
|
||
return clamp(projectedShadow, 0.0, 0.46);
|
||
}
|
||
|
||
float candlePlanarShadowLobe(vec3 point, vec3 flame, vec3 body, vec2 bodyData, float selfLight) {
|
||
vec2 lightToBody = body.xz - flame.xz;
|
||
float lightDistance = length(lightToBody);
|
||
vec2 direction = lightDistance > 0.012 ? lightToBody / lightDistance : normalize(vec2(0.42, 0.91));
|
||
vec2 perpendicular = vec2(-direction.y, direction.x);
|
||
vec2 delta = point.xz - body.xz;
|
||
float along = dot(delta, direction);
|
||
float side = abs(dot(delta, perpendicular));
|
||
float radius = bodyData.x;
|
||
float softContact = 1.0 - smoothstep(radius * 0.72, radius * 3.4, length(delta));
|
||
float lobeLength = mix(radius * 3.4, radius * (4.8 + lightDistance * 0.92), 1.0 - selfLight);
|
||
float lobeWidth = radius * (1.6 + max(along, 0.0) * mix(0.72, 0.46, selfLight));
|
||
float frontGate = smoothstep(-radius * 0.42, radius * 0.34, along);
|
||
float distanceFade = 1.0 - smoothstep(radius * 0.7, lobeLength, along);
|
||
float sideFade = 1.0 - smoothstep(lobeWidth * 0.54, lobeWidth, side);
|
||
float directional = frontGate * distanceFade * sideFade;
|
||
float heightFade = smoothstep(body.y - 0.02, body.y + bodyData.y * 0.72, flame.y);
|
||
float waxTransmission = mix(0.54, 0.78, selfLight);
|
||
float strength = mix(0.32, 0.2, selfLight) * heightFade * (1.0 - waxTransmission * 0.48);
|
||
return clamp(max(softContact * 0.2, directional * strength), 0.0, 0.38);
|
||
}
|
||
|
||
float candlePlanarShadowField(vec3 point) {
|
||
float shadow = 0.0;
|
||
for (int bodyIndex = 0; bodyIndex < 3; bodyIndex++) {
|
||
for (int flameIndex = 0; flameIndex < 3; flameIndex++) {
|
||
float selfLight = bodyIndex == flameIndex ? 1.0 : 0.0;
|
||
shadow = max(shadow, candlePlanarShadowLobe(point, candleFlamePositions[flameIndex], candleBodyPositions[bodyIndex], candleBodyData[bodyIndex], selfLight));
|
||
}
|
||
}
|
||
return clamp(shadow, 0.0, 0.5);
|
||
}
|
||
|
||
float bookUnpackRGBADepth(vec4 packedDepth) {
|
||
const vec4 unpackFactors = vec4(
|
||
1.0 / (256.0 * 256.0 * 256.0),
|
||
1.0 / (256.0 * 256.0),
|
||
1.0 / 256.0,
|
||
1.0
|
||
);
|
||
return dot(packedDepth, unpackFactors);
|
||
}
|
||
|
||
float bookShadowCompare(vec4 packedDepth, float currentDepth) {
|
||
float closestDepth = bookUnpackRGBADepth(packedDepth);
|
||
return smoothstep(0.001, 0.018, currentDepth - closestDepth - 0.0018);
|
||
}
|
||
|
||
float bookShadowSample0(vec4 shadowCoord) {
|
||
vec3 coord = shadowCoord.xyz / max(shadowCoord.w, 0.0001);
|
||
float inBounds = step(0.0, coord.x) * step(0.0, coord.y) *
|
||
step(coord.x, 1.0) * step(coord.y, 1.0) * step(0.0, coord.z) * step(coord.z, 1.0);
|
||
if (inBounds < 0.5) return 0.0;
|
||
|
||
float shadow = 0.0;
|
||
for (int x = -1; x <= 1; x++) {
|
||
for (int y = -1; y <= 1; y++) {
|
||
vec2 offset = vec2(float(x), float(y)) * bookShadowMapTexelSize * 1.65;
|
||
shadow += bookShadowCompare(texture2D(bookShadowMaps[0], coord.xy + offset), coord.z);
|
||
}
|
||
}
|
||
return clamp(shadow / 9.0, 0.0, 1.0) * inBounds;
|
||
}
|
||
|
||
float bookShadowSample1(vec4 shadowCoord) {
|
||
vec3 coord = shadowCoord.xyz / max(shadowCoord.w, 0.0001);
|
||
float inBounds = step(0.0, coord.x) * step(0.0, coord.y) *
|
||
step(coord.x, 1.0) * step(coord.y, 1.0) * step(0.0, coord.z) * step(coord.z, 1.0);
|
||
if (inBounds < 0.5) return 0.0;
|
||
|
||
float shadow = 0.0;
|
||
for (int x = -1; x <= 1; x++) {
|
||
for (int y = -1; y <= 1; y++) {
|
||
vec2 offset = vec2(float(x), float(y)) * bookShadowMapTexelSize * 1.65;
|
||
shadow += bookShadowCompare(texture2D(bookShadowMaps[1], coord.xy + offset), coord.z);
|
||
}
|
||
}
|
||
return clamp(shadow / 9.0, 0.0, 1.0) * inBounds;
|
||
}
|
||
|
||
float bookShadowSample2(vec4 shadowCoord) {
|
||
vec3 coord = shadowCoord.xyz / max(shadowCoord.w, 0.0001);
|
||
float inBounds = step(0.0, coord.x) * step(0.0, coord.y) *
|
||
step(coord.x, 1.0) * step(coord.y, 1.0) * step(0.0, coord.z) * step(coord.z, 1.0);
|
||
if (inBounds < 0.5) return 0.0;
|
||
|
||
float shadow = 0.0;
|
||
for (int x = -1; x <= 1; x++) {
|
||
for (int y = -1; y <= 1; y++) {
|
||
vec2 offset = vec2(float(x), float(y)) * bookShadowMapTexelSize * 1.65;
|
||
shadow += bookShadowCompare(texture2D(bookShadowMaps[2], coord.xy + offset), coord.z);
|
||
}
|
||
}
|
||
return clamp(shadow / 9.0, 0.0, 1.0) * inBounds;
|
||
}
|
||
|
||
float bookMeshShadowField(vec3 point) {
|
||
float shadow0 = bookShadowSample0(bookShadowMatrices[0] * vec4(point, 1.0));
|
||
float shadow1 = bookShadowSample1(bookShadowMatrices[1] * vec4(point, 1.0));
|
||
float shadow2 = bookShadowSample2(bookShadowMatrices[2] * vec4(point, 1.0));
|
||
return clamp(max(max(shadow0, shadow1), shadow2) * 0.62, 0.0, 0.62);
|
||
}`
|
||
)
|
||
.replace(
|
||
'#include <roughnessmap_fragment>',
|
||
`#include <roughnessmap_fragment>
|
||
float tableSpecularGrease = tableGreaseFromWorld(vTableWorldPosition);
|
||
float tableSpecularDust = tableDustFromWorld(vTableWorldPosition) * (1.0 - smoothstep(0.025, 0.18, tableSpecularGrease) * 0.82);
|
||
float tableSpecularDustFilm = smoothstep(0.006, 0.034, tableSpecularDust);
|
||
float tableSpecularGreaseFilm = smoothstep(0.016, 0.14, tableSpecularGrease);
|
||
roughnessFactor = clamp(mix(roughnessFactor, 0.84, tableSpecularDustFilm * 0.32), 0.04, 1.0);
|
||
roughnessFactor = clamp(mix(roughnessFactor, 0.34, tableSpecularGreaseFilm * 0.3), 0.04, 1.0);`
|
||
)
|
||
.replace(
|
||
'#include <opaque_fragment>',
|
||
`vec3 viewDirWorld = normalize(cameraPosition - vTableWorldPosition);
|
||
vec3 tableNormalWorld = normalize(vec3(normal.x * 0.18, 1.0, normal.z * 0.18));
|
||
vec3 reflectedDir = reflect(-viewDirWorld, tableNormalWorld);
|
||
vec3 roomReflection = sampleRoughRoomReflection(reflectedDir);
|
||
roomReflection = pow(max(roomReflection, vec3(0.0)), vec3(0.78));
|
||
roomReflection *= vec3(0.88, 0.7, 0.5);
|
||
vec2 sceneReflectionUv = vSceneReflectionCoord.xy / max(vSceneReflectionCoord.w, 0.0001);
|
||
float sceneReflectionInBounds = step(0.0, sceneReflectionUv.x) * step(0.0, sceneReflectionUv.y) *
|
||
step(sceneReflectionUv.x, 1.0) * step(sceneReflectionUv.y, 1.0);
|
||
float sceneReflectionEdge = smoothstep(0.0, 0.08, sceneReflectionUv.x) *
|
||
smoothstep(0.0, 0.08, sceneReflectionUv.y) *
|
||
smoothstep(0.0, 0.08, 1.0 - sceneReflectionUv.x) *
|
||
smoothstep(0.0, 0.08, 1.0 - sceneReflectionUv.y);
|
||
vec3 sceneReflection = texture2D(sceneReflectionMap, sceneReflectionUv).rgb;
|
||
sceneReflection = pow(max(sceneReflection, vec3(0.0)), vec3(0.88)) * sceneReflectionInBounds * sceneReflectionEdge;
|
||
float grease = tableGreaseFromWorld(vTableWorldPosition);
|
||
float greaseDustWipe = smoothstep(0.016, 0.13, grease);
|
||
float dust = tableDustFromWorld(vTableWorldPosition) * (1.0 - greaseDustWipe * 0.82);
|
||
float dustFilm = smoothstep(0.006, 0.034, dust);
|
||
float greaseFilm = smoothstep(0.016, 0.14, grease);
|
||
float reflectionCleanliness = 1.0 - dustFilm * 0.2 - greaseFilm * 0.06;
|
||
vec3 dustBlurredSceneReflection = sceneReflection * 0.78 + roomReflection * 0.055;
|
||
vec3 greaseBlurredSceneReflection = sceneReflection * 0.7 + roomReflection * 0.18;
|
||
vec3 dustAwareSceneReflection = mix(sceneReflection, dustBlurredSceneReflection, dustFilm);
|
||
dustAwareSceneReflection = mix(dustAwareSceneReflection, greaseBlurredSceneReflection, greaseFilm);
|
||
vec3 combinedReflection = (roomReflection * 0.14 + dustAwareSceneReflection * 0.86) * reflectionCleanliness;
|
||
float fresnel = pow(1.0 - max(dot(viewDirWorld, tableNormalWorld), 0.0), 1.85);
|
||
float tableReflectionMask = smoothstep(-0.095, -0.025, vTableWorldPosition.y);
|
||
vec3 reflectedSurface = combinedReflection * (0.64 + fresnel * 0.36);
|
||
float reflectedLuma = dot(reflectedSurface, vec3(0.299, 0.587, 0.114));
|
||
reflectedSurface = mix(reflectedSurface, vec3(reflectedLuma), dustFilm * 0.42);
|
||
float candleProjectedShadow = max(
|
||
max(candleProjectedShadowField(vTableWorldPosition), candlePlanarShadowField(vTableWorldPosition)),
|
||
bookMeshShadowField(vTableWorldPosition)
|
||
) * tableReflectionMask;
|
||
float candleOcclusion = clamp(candleProjectedShadow * 1.46, 0.0, 0.82);
|
||
vec3 normalDebug = normalize(normal) * 0.5 + 0.5;
|
||
outgoingLight = mix(outgoingLight, reflectedSurface, tableReflectionMask * (0.16 + fresnel * 0.28 + greaseFilm * 0.065) * reflectionCleanliness);
|
||
outgoingLight += tableReflectionMask * roomReflection * 0.004 * reflectionCleanliness;
|
||
outgoingLight += tableReflectionMask * dustFilm * vec3(0.008, 0.0085, 0.009) * (0.22 + fresnel * 0.62);
|
||
outgoingLight += tableReflectionMask * greaseFilm * vec3(0.018, 0.013, 0.007) * (0.28 + fresnel * 0.58);
|
||
outgoingLight *= mix(vec3(1.0), vec3(0.19, 0.15, 0.115), candleOcclusion);
|
||
if (tableDebugMode == 1) outgoingLight = vec3(candleProjectedShadow);
|
||
if (tableDebugMode == 2) outgoingLight = vec3(dust);
|
||
if (tableDebugMode == 3) outgoingLight = normalDebug;
|
||
if (tableDebugMode == 4) outgoingLight = roomReflection;
|
||
if (tableDebugMode == 5) outgoingLight = sceneReflection;
|
||
if (tableDebugMode == 6) outgoingLight = vec3(tableReflectionMask);
|
||
if (tableDebugMode == 8) outgoingLight = vec3(grease);
|
||
if (tableDebugMode == 10) outgoingLight = combinedReflection;
|
||
#include <opaque_fragment>`
|
||
);
|
||
};
|
||
}
|
||
|
||
function buildBook() {
|
||
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)));
|
||
// Await the (now async, worker-backed) draws so the spreads are resident before the cache
|
||
// lookup below — otherwise the flip can race ahead and find a missing texture.
|
||
await prepareSpreadTextureRecordsForFlip(currentSpread);
|
||
await 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
|
||
};
|
||
}
|
||
|
||
async function prepareSpreadTextureRecordsForFlip(spreadIndex) {
|
||
const spread = getPaginationSpread(spreadIndex);
|
||
if (!spread || typeof window.BookTextureRenderer?.drawSpread !== 'function') return false;
|
||
if (spreadTextureRecordsReady(spread)) return true;
|
||
await 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');
|
||
compileFlipMaterialsForLoader();
|
||
markLoaderTiming('shaderCompile:end');
|
||
// Run the full post-processing pipeline now so the SSAO and output passes compile their
|
||
// programs and allocate their render targets during the loader, instead of stalling the
|
||
// first live frames after the loader fades out.
|
||
markLoaderTiming('composerWarmup:start');
|
||
if (composer) {
|
||
composer.render();
|
||
composer.render();
|
||
}
|
||
markLoaderTiming('composerWarmup:end');
|
||
staticSceneBuffersDirty = false;
|
||
markLoaderTiming('primeSceneForLoader:end');
|
||
}
|
||
|
||
// The flipping page mesh is only created on the first page flip, so its materials are not in
|
||
// the scene graph that renderer.compile walks. Compile them now via a throwaway probe mesh so
|
||
// the first flip does not stutter while the GPU compiles the flip page program.
|
||
function compileFlipMaterialsForLoader() {
|
||
const probeGeometry = new THREE.PlaneGeometry(0.001, 0.001);
|
||
const probes = [materials.flipPageSurface, materials.flipPageBackSurface, materials.flipPageEdge]
|
||
.map((material) => {
|
||
const probe = new THREE.Mesh(probeGeometry, material);
|
||
probe.position.copy(book.position);
|
||
probe.visible = true;
|
||
book.add(probe);
|
||
return probe;
|
||
});
|
||
renderer.compile(scene, camera);
|
||
probes.forEach((probe) => book.remove(probe));
|
||
probeGeometry.dispose();
|
||
}
|
||
|
||
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 = '';
|
||
canvas.releasePointerCapture(event.pointerId);
|
||
});
|
||
|
||
canvas.addEventListener('pointercancel', () => {
|
||
cameraRig.dragging = false;
|
||
cameraRig.navigationActive = false;
|
||
cameraRig.keys.clear();
|
||
canvas.style.cursor = '';
|
||
});
|
||
|
||
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 geometryAnimating = activeFlips.length > 0;
|
||
const bufferRefreshIntervalMs = geometryAnimating ? dynamicBufferRefreshIntervalMs : staticGeometryBufferRefreshIntervalMs;
|
||
const shadowRefreshDue = !deferDynamicBuffersForFlipStart && (
|
||
forceDynamicBufferRefresh || now - lastBookShadowRefreshAt >= bufferRefreshIntervalMs
|
||
);
|
||
const reflectionRefreshDue = !deferDynamicBuffersForFlipStart && (
|
||
forceDynamicBufferRefresh || now - lastTableReflectionRefreshAt >= bufferRefreshIntervalMs
|
||
);
|
||
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);
|
||
}
|