4425 lines
191 KiB
JavaScript
4425 lines
191 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-a';
|
|
|
|
const canvas = document.getElementById('scene');
|
|
canvas.style.cursor = 'grab';
|
|
const tableDebugModes = {
|
|
none: 0,
|
|
shadow: 1,
|
|
dust: 2,
|
|
normal: 3,
|
|
room: 4,
|
|
scene: 5,
|
|
mask: 6,
|
|
ao: 7,
|
|
grease: 8,
|
|
mirror: 10
|
|
};
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const appInitialState = window.WebGLBookInitialState || {};
|
|
const tableDebugName = urlParams.get('tableDebug') || 'none';
|
|
const tableDebugMode = tableDebugModes[tableDebugName] ?? tableDebugModes.none;
|
|
const isAppIntegrationMode = appInitialState.appMode === true;
|
|
const appRenderPixelRatio = 2;
|
|
const labStatus = document.getElementById('lab_status');
|
|
if (labStatus && tableDebugMode !== tableDebugModes.none) {
|
|
labStatus.textContent = tableDebugName === 'ao' ? 'scene debug: SSAO' : `table debug: ${tableDebugName}`;
|
|
}
|
|
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
|
|
renderer.setPixelRatio(appRenderPixelRatio);
|
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
renderer.toneMappingExposure = 1.12;
|
|
renderer.shadowMap.enabled = false;
|
|
renderer.shadowMap.type = THREE.VSMShadowMap;
|
|
|
|
const generatedTextureCanvases = {};
|
|
const maxTextureAnisotropy = renderer.capabilities.getMaxAnisotropy();
|
|
const reflectionPixelRatio = 1;
|
|
const pageTextureWidth = 3072;
|
|
const reflectionTargetSize = new THREE.Vector2();
|
|
const pageRaycaster = new THREE.Raycaster();
|
|
const pointerNdc = new THREE.Vector2();
|
|
let sceneComposerTarget = null;
|
|
let composer = null;
|
|
let sceneRenderPass = null;
|
|
let sceneAoPass = null;
|
|
let sceneSmaaPass = null;
|
|
let sceneOutputPass = null;
|
|
const aoExcludedObjects = new Set();
|
|
let renderedFrameCount = 0;
|
|
let staticSceneBuffersDirty = true;
|
|
let lastStaticCameraSignature = '';
|
|
let lastResizeWidth = 0;
|
|
let lastResizeHeight = 0;
|
|
|
|
function reportLabProgress(percent, message) {
|
|
if (typeof appInitialState.reportProgress === 'function') {
|
|
appInitialState.reportProgress(percent, message);
|
|
}
|
|
}
|
|
|
|
async function reportLabStep(percent, message) {
|
|
reportLabProgress(percent, message);
|
|
await new Promise(resolve => requestAnimationFrame(resolve));
|
|
}
|
|
|
|
const scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(0x080604);
|
|
scene.fog = new THREE.FogExp2(0x080604, 0.035);
|
|
let candleBounceLight = null;
|
|
let tableMesh = null;
|
|
let tableShader = null;
|
|
let tableRoomReflectionTexture = createRoomReflectionTexture();
|
|
let tableDustTexture = null;
|
|
let tableGreaseTexture = null;
|
|
const tableTopY = -0.02;
|
|
const bookTableContactClearance = 0.002;
|
|
const tableReflectionBaseWidth = 2048;
|
|
const tableReflectionBaseHeight = 1152;
|
|
const tableReflectionTarget = new THREE.WebGLRenderTarget(tableReflectionBaseWidth, tableReflectionBaseHeight, {
|
|
colorSpace: THREE.SRGBColorSpace,
|
|
depthBuffer: true,
|
|
stencilBuffer: false,
|
|
samples: renderer.capabilities.isWebGL2 ? 8 : 0
|
|
});
|
|
tableReflectionTarget.texture.colorSpace = THREE.SRGBColorSpace;
|
|
tableReflectionTarget.texture.minFilter = THREE.LinearFilter;
|
|
tableReflectionTarget.texture.magFilter = THREE.LinearFilter;
|
|
tableReflectionTarget.texture.anisotropy = maxTextureAnisotropy;
|
|
const tableReflectionCamera = new THREE.PerspectiveCamera();
|
|
const tableReflectionMatrix = new THREE.Matrix4();
|
|
const tableReflectionBiasMatrix = new THREE.Matrix4().set(
|
|
0.5, 0, 0, 0.5,
|
|
0, 0.5, 0, 0.5,
|
|
0, 0, 0.5, 0.5,
|
|
0, 0, 0, 1
|
|
);
|
|
const reflectionTarget = new THREE.Vector3();
|
|
const reflectionUp = new THREE.Vector3();
|
|
const candleShadowSources = [];
|
|
const candleWorldPosition = new THREE.Vector3();
|
|
const flameWorldPosition = new THREE.Vector3();
|
|
const bookShadowMapSize = 1536;
|
|
const bookShadowTargets = Array.from({ length: 3 }, () => {
|
|
const target = new THREE.WebGLRenderTarget(bookShadowMapSize, bookShadowMapSize, {
|
|
colorSpace: THREE.NoColorSpace,
|
|
depthBuffer: true,
|
|
stencilBuffer: false
|
|
});
|
|
target.texture.colorSpace = THREE.NoColorSpace;
|
|
target.texture.minFilter = THREE.LinearFilter;
|
|
target.texture.magFilter = THREE.LinearFilter;
|
|
target.texture.generateMipmaps = false;
|
|
return target;
|
|
});
|
|
const bookShadowCameras = Array.from({ length: 3 }, () => new THREE.PerspectiveCamera(78, 1, 0.03, 7.2));
|
|
const bookShadowMatrices = Array.from({ length: 3 }, () => new THREE.Matrix4());
|
|
const bookShadowBiasMatrix = new THREE.Matrix4().set(
|
|
0.5, 0, 0, 0.5,
|
|
0, 0.5, 0, 0.5,
|
|
0, 0, 0.5, 0.5,
|
|
0, 0, 0, 1
|
|
);
|
|
const bookShadowDepthMaterial = new THREE.MeshDepthMaterial({
|
|
depthPacking: THREE.RGBADepthPacking
|
|
});
|
|
bookShadowDepthMaterial.blending = THREE.NoBlending;
|
|
|
|
const camera = new THREE.PerspectiveCamera(28, 1, 0.1, 40);
|
|
const cameraRig = {
|
|
target: new THREE.Vector3(0, 0.16, -0.04),
|
|
yaw: 0,
|
|
pitch: 1.06,
|
|
radius: 5.54,
|
|
minPitch: 0.28,
|
|
maxPitch: 1.34,
|
|
minRadius: 2.4,
|
|
maxRadius: 9.0,
|
|
dragging: false,
|
|
navigationActive: false,
|
|
pointerX: 0,
|
|
pointerY: 0,
|
|
keys: new Set()
|
|
};
|
|
if (urlParams.get('view') === 'wide') {
|
|
cameraRig.target.set(0, 0.05, 0);
|
|
cameraRig.pitch = 0.96;
|
|
cameraRig.radius = 7.8;
|
|
}
|
|
updateCameraRig(0);
|
|
|
|
configureScenePostprocessing();
|
|
|
|
const clock = new THREE.Clock();
|
|
const targetFrameDurationMs = 1000 / 60;
|
|
let lastRenderFrameAt = 0;
|
|
let fpsDisplay = null;
|
|
let fpsWindowStartedAt = performance.now();
|
|
let fpsWindowFrames = 0;
|
|
const lastFrameTiming = {};
|
|
const loaderTimings = {};
|
|
const pageTextureTimings = [];
|
|
|
|
function markLoaderTiming(name) {
|
|
loaderTimings[name] = performance.now();
|
|
document.documentElement.dataset.webglLoaderTimings = JSON.stringify(loaderTimings);
|
|
}
|
|
|
|
function markPageTextureTiming(name, detail = {}) {
|
|
const entry = {
|
|
name,
|
|
at: performance.now(),
|
|
detail
|
|
};
|
|
pageTextureTimings.push(entry);
|
|
if (pageTextureTimings.length > 120) pageTextureTimings.splice(0, pageTextureTimings.length - 120);
|
|
document.documentElement.dataset.webglPageTextureTimings = JSON.stringify(pageTextureTimings);
|
|
return entry;
|
|
}
|
|
|
|
const book = new THREE.Group();
|
|
scene.add(book);
|
|
const initialReadingProgress = THREE.MathUtils.clamp(Number.parseFloat(urlParams.get('progress') ?? appInitialState.progress ?? '0'), 0, 1);
|
|
let readingProgress = Number.isFinite(initialReadingProgress) ? initialReadingProgress : 0;
|
|
let bookPageCount = snapProceduralPageCount(urlParams.get('pages') ?? appInitialState.pageCount ?? '300');
|
|
let pageReserve = clampPageReserve(appInitialState.pageReserve ?? 50, bookPageCount);
|
|
let currentProceduralBookModel = null;
|
|
const progressInput = document.getElementById('progress_control');
|
|
const progressValue = document.getElementById('progress_value');
|
|
const pageCountInput = document.getElementById('page_count_control');
|
|
const pageCountValue = document.getElementById('page_count_value');
|
|
const fastBackwardButton = document.getElementById('fast_backward');
|
|
const backwardButton = document.getElementById('flip_backward');
|
|
const forwardButton = document.getElementById('flip_forward');
|
|
const fastForwardButton = document.getElementById('fast_forward');
|
|
let bottomNavigation = null;
|
|
let bookPaginationState = {
|
|
spreadIndex: 0,
|
|
spreadCount: 1,
|
|
writtenPageLimit: 0
|
|
};
|
|
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
|
|
};
|
|
let pendingRightPageFlip = false;
|
|
let pendingRightPageFlipAutoplay = false;
|
|
const pageRevealState = {
|
|
left: null,
|
|
right: null
|
|
};
|
|
let pageRevealFreezeAt = null;
|
|
const pageRevealClearLog = [];
|
|
await reportLabStep(52, 'Generating leather texture set');
|
|
const leatherTextures = createLeatherTextures();
|
|
await reportLabStep(56, 'Generating spine cloth texture set');
|
|
const spineClothTextures = createSpineClothTextures();
|
|
await reportLabStep(60, 'Generating headband texture set');
|
|
const headbandTextures = createHeadbandTextures();
|
|
await reportLabStep(64, 'Generating paper texture set');
|
|
const paperTextures = createHardcoverPaperTextures();
|
|
await reportLabStep(68, 'Creating WebGL book materials');
|
|
|
|
const materials = {
|
|
leather: new THREE.MeshStandardMaterial({
|
|
color: 0x25130b,
|
|
map: leatherTextures.color,
|
|
normalMap: leatherTextures.normal,
|
|
normalScale: new THREE.Vector2(0.07, 0.07),
|
|
roughnessMap: leatherTextures.roughness,
|
|
roughness: 0.78,
|
|
metalness: 0.02,
|
|
envMapIntensity: 0.1,
|
|
side: THREE.DoubleSide
|
|
}),
|
|
hingeLeather: new THREE.MeshStandardMaterial({
|
|
color: 0x32180c,
|
|
map: leatherTextures.color,
|
|
normalMap: leatherTextures.normal,
|
|
normalScale: new THREE.Vector2(0.062, 0.062),
|
|
roughnessMap: leatherTextures.roughness,
|
|
roughness: 0.82,
|
|
metalness: 0.015,
|
|
envMapIntensity: 0.08,
|
|
side: THREE.DoubleSide
|
|
}),
|
|
spineBaseLeather: new THREE.MeshStandardMaterial({
|
|
color: 0x2a1209,
|
|
map: leatherTextures.color,
|
|
normalMap: leatherTextures.normal,
|
|
normalScale: new THREE.Vector2(0.055, 0.055),
|
|
roughnessMap: leatherTextures.roughness,
|
|
roughness: 0.86,
|
|
metalness: 0.01,
|
|
envMapIntensity: 0.06,
|
|
side: THREE.DoubleSide
|
|
}),
|
|
coverEdge: new THREE.MeshStandardMaterial({
|
|
color: 0x5b351b,
|
|
map: leatherTextures.color,
|
|
normalMap: leatherTextures.normal,
|
|
normalScale: new THREE.Vector2(0.068, 0.068),
|
|
roughnessMap: leatherTextures.roughness,
|
|
roughness: 0.8,
|
|
metalness: 0.015,
|
|
envMapIntensity: 0.07,
|
|
side: THREE.DoubleSide
|
|
}),
|
|
pageBlock: new THREE.MeshStandardMaterial({
|
|
color: 0xeee6cc,
|
|
map: paperTextures.color,
|
|
normalMap: paperTextures.normal,
|
|
normalScale: new THREE.Vector2(0.008, 0.008),
|
|
roughnessMap: paperTextures.roughness,
|
|
roughness: 0.88,
|
|
metalness: 0,
|
|
envMapIntensity: 0.025
|
|
}),
|
|
pageEdge: new THREE.MeshStandardMaterial({
|
|
color: 0xe8ddbe,
|
|
map: paperTextures.edge,
|
|
normalMap: paperTextures.normal,
|
|
normalScale: new THREE.Vector2(0.008, 0.008),
|
|
roughnessMap: paperTextures.roughness,
|
|
roughness: 0.94,
|
|
metalness: 0,
|
|
envMapIntensity: 0.02
|
|
}),
|
|
pageSurface: new THREE.MeshStandardMaterial({
|
|
color: 0xeee6cc,
|
|
map: paperTextures.color,
|
|
normalMap: paperTextures.normal,
|
|
normalScale: new THREE.Vector2(0.006, 0.006),
|
|
roughnessMap: paperTextures.roughness,
|
|
roughness: 0.9,
|
|
metalness: 0,
|
|
emissive: 0x14110b,
|
|
emissiveIntensity: 0.004,
|
|
envMapIntensity: 0.012,
|
|
side: THREE.DoubleSide
|
|
}),
|
|
flipPageSurface: new THREE.MeshStandardMaterial({
|
|
color: 0xeee6cc,
|
|
roughness: 0.92,
|
|
metalness: 0,
|
|
emissive: 0x100d08,
|
|
emissiveIntensity: 0.004,
|
|
envMapIntensity: 0.01,
|
|
side: THREE.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.leftPage.userData.bookPageReveal = {
|
|
side: 'left'
|
|
};
|
|
materials.rightPage.userData.bookPageReveal = {
|
|
side: 'right'
|
|
};
|
|
materials.spineCloth.userData.isSpineCloth = true;
|
|
materials.headband.userData.isHeadband = true;
|
|
configureHardcoverPaperMaterial(materials.pageBlock);
|
|
configureHardcoverPaperMaterial(materials.pageEdge, { useEdgeMap: true });
|
|
configureHardcoverPaperMaterial(materials.pageSurface);
|
|
configureHardcoverPaperMaterial(materials.leftPage);
|
|
configureHardcoverPaperMaterial(materials.rightPage);
|
|
|
|
configureBookShadowReceiver(materials.leather, 0.52);
|
|
configureBookShadowReceiver(materials.hingeLeather, 0.36);
|
|
configureBookShadowReceiver(materials.spineBaseLeather, 0.34);
|
|
configureBookShadowReceiver(materials.coverEdge, 0.28);
|
|
configureBookShadowReceiver(materials.pageBlock, 0.18);
|
|
configureBookShadowReceiver(materials.pageEdge, 0.16);
|
|
configureBookShadowReceiver(materials.pageSurface, 0.11);
|
|
configureBookShadowReceiver(materials.flipPageSurface, 0.11);
|
|
configureBookShadowReceiver(materials.flipPageBackSurface, 0.11);
|
|
configureBookShadowReceiver(materials.flipPageEdge, 0.09);
|
|
configureBookShadowReceiver(materials.leftPage, 0.08);
|
|
configureBookShadowReceiver(materials.rightPage, 0.08);
|
|
configureBookShadowReceiver(materials.spineCloth, 0.48);
|
|
configureBookShadowReceiver(materials.headband, 0.62);
|
|
|
|
await reportLabStep(70, 'Building reflective table');
|
|
buildTable();
|
|
await reportLabStep(74, 'Building candle lighting');
|
|
buildLighting();
|
|
await reportLabStep(78, 'Building physical book stack');
|
|
buildBook();
|
|
notifyBookPageCountChanged();
|
|
await reportLabStep(82, 'Loading room reflection texture');
|
|
await loadAiRoomReflection();
|
|
await reportLabStep(86, 'Preparing static shadow and mirror maps');
|
|
resize();
|
|
primeSceneForLoader();
|
|
await reportLabStep(90, 'Compiled WebGL scene passes');
|
|
window.BookLabDebug = {
|
|
cacheKey: window.MODULE_CACHE_BUSTER || null,
|
|
textures: generatedTextureCanvases,
|
|
ready: false,
|
|
renderedFrames: 0,
|
|
loaderTimings,
|
|
pageTextureTimings,
|
|
pageRevealClearLog,
|
|
get sceneAoPass() {
|
|
return sceneAoPass;
|
|
},
|
|
get composer() {
|
|
return composer;
|
|
},
|
|
get readingProgress() {
|
|
return readingProgress;
|
|
},
|
|
get bookModel() {
|
|
return currentProceduralBookModel;
|
|
},
|
|
get bookConstants() {
|
|
return PROCEDURAL_BOOK;
|
|
},
|
|
get bookPlacement() {
|
|
return {
|
|
tableTopY,
|
|
bookGroupY: book.position.y,
|
|
contactClearance: book.position.y - tableTopY
|
|
};
|
|
},
|
|
get bookParts() {
|
|
const parts = [];
|
|
book.traverse((object) => {
|
|
if (!object.isMesh) return;
|
|
parts.push({
|
|
part: object.userData.bookPart ?? 'unknown',
|
|
castShadow: object.castShadow,
|
|
receiveShadow: object.receiveShadow,
|
|
materialType: Array.isArray(object.material)
|
|
? object.material.map((material) => material.type).join(',')
|
|
: object.material?.type
|
|
});
|
|
});
|
|
return parts;
|
|
},
|
|
get activeFlips() {
|
|
return activeFlips.length;
|
|
},
|
|
get pendingPageFlips() {
|
|
return pendingPageFlips;
|
|
},
|
|
setReadingProgress(value) {
|
|
setReadingProgress(value);
|
|
return readingProgress;
|
|
},
|
|
setBookPageCount(value) {
|
|
setBookPageCount(value);
|
|
return bookPageCount;
|
|
},
|
|
setPageReserve(value) {
|
|
setPageReserve(value);
|
|
return pageReserve;
|
|
},
|
|
getBookState() {
|
|
return {
|
|
pageCount: bookPageCount,
|
|
pageReserve,
|
|
progress: readingProgress,
|
|
pagePosition: getCurrentPagePosition(),
|
|
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;
|
|
},
|
|
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,
|
|
mirrorRefreshesEveryFrame: true,
|
|
mirrorRefreshesWhenStaticDirty: true,
|
|
lastFlipTexturePreflight
|
|
};
|
|
},
|
|
projectPointerToPage(clientX, clientY) {
|
|
return projectPointerToPage(clientX, clientY);
|
|
},
|
|
exportTexture(name) {
|
|
if (name === 'left' || name === 'leftPage') return leftTexture.image?.toDataURL?.('image/png') || leftCanvas.toDataURL('image/png');
|
|
if (name === 'right' || name === 'rightPage') return rightTexture.image?.toDataURL?.('image/png') || rightCanvas.toDataURL('image/png');
|
|
return generatedTextureCanvases[name]?.toDataURL('image/png') || null;
|
|
}
|
|
};
|
|
|
|
window.addEventListener('resize', resize);
|
|
document.addEventListener('webgl-book:page-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:reveal-committed', (event) => {
|
|
handleRevealCommittedForPageFlip(event.detail || {});
|
|
});
|
|
document.addEventListener('webgl-book:page-cache-problem', (event) => {
|
|
pageTextureStore?.recordProblem?.(event.detail || {});
|
|
});
|
|
document.addEventListener('book-pagination:spread-updated', (event) => {
|
|
const detail = event.detail || {};
|
|
const incomingSpreadIndex = Math.max(0, Number(detail.spreadIndex || 0));
|
|
const latestBlockId = Math.max(0, Number(detail.latestBlockId || 0));
|
|
const latestRenderedBlockId = Math.max(0, Number(detail.latestRenderedBlockId || 0));
|
|
if (
|
|
latestBlockId > latestRenderedBlockId
|
|
&& detail.visibility !== 'future-ready'
|
|
&& activeFlips.length === 0
|
|
&& incomingSpreadIndex > Math.max(0, Number(bookPaginationState.spreadIndex || 0))
|
|
) {
|
|
markPageTextureTiming('spreadUpdate:deferred-future-unrendered', {
|
|
incomingSpreadIndex,
|
|
visibleSpreadIndex: bookPaginationState.spreadIndex,
|
|
latestBlockId,
|
|
latestRenderedBlockId
|
|
});
|
|
return;
|
|
}
|
|
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);
|
|
}
|
|
syncBookControls();
|
|
if (pendingRightPageFlip) tryStartPendingRightPageFlip('spread-updated');
|
|
});
|
|
document.addEventListener('webgl-book:page-reserve-directive', (event) => {
|
|
const detail = event.detail || {};
|
|
const value = Number(detail.value);
|
|
if (!Number.isFinite(value)) return;
|
|
const nextReserve = detail.unit === 'percent'
|
|
? Math.round(bookPageCount * (value / 100))
|
|
: Math.round(value);
|
|
setPageReserve(nextReserve);
|
|
});
|
|
document.addEventListener('webgl-book:request-page-flip', (event) => {
|
|
const detail = event.detail || {};
|
|
const direction = Math.sign(Number(detail.direction || 1)) || 1;
|
|
const targetSpread = Number.isFinite(Number(detail.targetSpread))
|
|
? Math.max(0, Math.round(Number(detail.targetSpread)))
|
|
: null;
|
|
startPageFlip(direction, {
|
|
force: detail.force === true,
|
|
targetSpread
|
|
});
|
|
});
|
|
document.addEventListener('ui:command', (event) => {
|
|
if (event.detail?.type === 'continue' && pendingRightPageFlip) {
|
|
tryStartPendingRightPageFlip('continue', { force: true });
|
|
}
|
|
});
|
|
installBookControls();
|
|
installCameraControls();
|
|
resize();
|
|
document.dispatchEvent(new CustomEvent('webgl-book:scene-ready'));
|
|
ensureFpsDisplay();
|
|
animate();
|
|
|
|
function ensureFpsDisplay() {
|
|
if (fpsDisplay) return fpsDisplay;
|
|
fpsDisplay = document.createElement('div');
|
|
fpsDisplay.id = 'webgl_fps_display';
|
|
Object.assign(fpsDisplay.style, {
|
|
position: 'fixed',
|
|
top: '0.65rem',
|
|
right: '0.75rem',
|
|
zIndex: '80',
|
|
minWidth: '4.2rem',
|
|
padding: '0.22rem 0.42rem',
|
|
border: '1px solid rgba(246, 231, 201, 0.28)',
|
|
background: 'rgba(10, 7, 4, 0.62)',
|
|
color: 'rgba(255, 238, 202, 0.94)',
|
|
font: '12px ui-monospace, SFMono-Regular, Consolas, monospace',
|
|
lineHeight: '1.2',
|
|
textAlign: 'right',
|
|
pointerEvents: 'none'
|
|
});
|
|
fpsDisplay.textContent = '0 fps';
|
|
document.body.appendChild(fpsDisplay);
|
|
return fpsDisplay;
|
|
}
|
|
|
|
function buildTable() {
|
|
const tableTexture = new THREE.TextureLoader().load('/assets/webgl/wood_table_diff_1k.jpg');
|
|
tableTexture.colorSpace = THREE.SRGBColorSpace;
|
|
tableTexture.wrapS = THREE.RepeatWrapping;
|
|
tableTexture.wrapT = THREE.RepeatWrapping;
|
|
tableTexture.repeat.set(2.15, 1.45);
|
|
tableTexture.anisotropy = renderer.capabilities.getMaxAnisotropy();
|
|
const tableNormal = loadUtilityTexture('/assets/webgl/table_normal_2k.png');
|
|
tableNormal.wrapS = THREE.RepeatWrapping;
|
|
tableNormal.wrapT = THREE.RepeatWrapping;
|
|
tableNormal.repeat.set(2.15, 1.45);
|
|
tableNormal.anisotropy = renderer.capabilities.getMaxAnisotropy();
|
|
tableDustTexture = loadUtilityTexture('/assets/webgl/table_dust_4k.png', { maxSize: 2048 });
|
|
tableDustTexture.wrapS = THREE.ClampToEdgeWrapping;
|
|
tableDustTexture.wrapT = THREE.ClampToEdgeWrapping;
|
|
tableDustTexture.anisotropy = renderer.capabilities.getMaxAnisotropy();
|
|
tableGreaseTexture = loadUtilityTexture('/assets/webgl/table_grease_4k.png', { maxSize: 2048 });
|
|
tableGreaseTexture.wrapS = THREE.ClampToEdgeWrapping;
|
|
tableGreaseTexture.wrapT = THREE.ClampToEdgeWrapping;
|
|
tableGreaseTexture.anisotropy = renderer.capabilities.getMaxAnisotropy();
|
|
|
|
const tableMaterial = new THREE.MeshPhysicalMaterial({
|
|
color: 0x8a4c22,
|
|
map: tableTexture,
|
|
normalMap: tableNormal,
|
|
normalScale: new THREE.Vector2(0.22, 0.18),
|
|
roughness: 0.42,
|
|
metalness: 0,
|
|
clearcoat: 0.32,
|
|
clearcoatRoughness: 0.58,
|
|
reflectivity: 0.18,
|
|
envMapIntensity: 0
|
|
});
|
|
configureTableShader(tableMaterial);
|
|
tableMesh = new THREE.Mesh(new THREE.BoxGeometry(9.8, 0.28, 6.6, 1, 1, 1), tableMaterial);
|
|
tableMesh.position.y = -0.16;
|
|
tableMesh.receiveShadow = false;
|
|
scene.add(tableMesh);
|
|
}
|
|
|
|
function loadUtilityTexture(url, options = {}) {
|
|
const texture = new THREE.TextureLoader().load(url, (loadedTexture) => {
|
|
const maxSize = Math.max(0, Number(options.maxSize || 0));
|
|
const image = loadedTexture.image;
|
|
const width = Number(image?.naturalWidth || image?.width || 0);
|
|
const height = Number(image?.naturalHeight || image?.height || 0);
|
|
if (!maxSize || !width || !height || (width <= maxSize && height <= maxSize)) return;
|
|
const scale = Math.min(maxSize / width, maxSize / height);
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = Math.max(1, Math.round(width * scale));
|
|
canvas.height = Math.max(1, Math.round(height * scale));
|
|
canvas.getContext('2d')?.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
loadedTexture.image = canvas;
|
|
loadedTexture.needsUpdate = true;
|
|
});
|
|
texture.colorSpace = THREE.NoColorSpace;
|
|
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
|
texture.magFilter = THREE.LinearFilter;
|
|
texture.generateMipmaps = true;
|
|
return texture;
|
|
}
|
|
|
|
function configureBookShadowReceiver(material, strength) {
|
|
const isSpineCloth = material.userData?.isSpineCloth === true;
|
|
const isHardcoverPaper = material.userData?.isHardcoverPaper === true;
|
|
const isHeadband = material.userData?.isHeadband === true;
|
|
const pageReveal = material.userData?.bookPageReveal || null;
|
|
material.customProgramCacheKey = () => `book-shadow-receiver-${strength.toFixed(2)}-${pageReveal ? 'page-reveal-line-v1' : isHeadband ? 'headband-v1' : isSpineCloth ? 'spine-cloth-v4' : isHardcoverPaper ? 'hardcover-paper-v1' : 'plain'}`;
|
|
material.onBeforeCompile = (shader) => {
|
|
shader.uniforms.bookShadowMaps = { value: bookShadowTargets.map((target) => target.texture) };
|
|
shader.uniforms.bookShadowMatrices = { value: bookShadowMatrices };
|
|
shader.uniforms.bookShadowMapTexelSize = { value: new THREE.Vector2(1 / bookShadowMapSize, 1 / bookShadowMapSize) };
|
|
shader.uniforms.bookShadowReceiverStrength = { value: strength };
|
|
shader.uniforms.bookTableTopY = { value: tableTopY };
|
|
if (pageReveal) {
|
|
shader.uniforms.bookRevealActive = { value: 0 };
|
|
shader.uniforms.bookRevealElapsedMs = { value: completedRevealElapsedMs };
|
|
shader.uniforms.bookRevealRegionCount = { value: 0 };
|
|
shader.uniforms.bookRevealRegionRects = { value: Array.from({ length: maxRevealRegions }, () => new THREE.Vector4(0, 0, 0, 0)) };
|
|
shader.uniforms.bookRevealRegionTimings = { value: Array.from({ length: maxRevealRegions }, () => new THREE.Vector4(0, 1, 0, 0)) };
|
|
shader.uniforms.bookRevealPaperColor = { value: paperColor.clone() };
|
|
shader.uniforms.bookRevealBaseMap = { value: leftTexture };
|
|
shader.uniforms.bookRevealUseBaseMap = { value: 0 };
|
|
shader.uniforms.bookRevealSoftness = { value: 0.025 };
|
|
material.userData.bookRevealShader = shader;
|
|
applyPendingPageReveal(pageReveal.side, shader);
|
|
}
|
|
|
|
shader.vertexShader = shader.vertexShader
|
|
.replace(
|
|
'#include <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);
|
|
}
|
|
|
|
function syncReadingProgressToCurrentPage() {
|
|
const nextProgress = THREE.MathUtils.clamp(getCurrentPagePosition() / Math.max(1, bookPageCount), 0, 1);
|
|
if (Math.abs(nextProgress - readingProgress) < 0.0001) return;
|
|
readingProgress = nextProgress;
|
|
buildBook();
|
|
window.WebGLBookPreferenceBridge?.updateProgress?.(readingProgress);
|
|
}
|
|
|
|
function growBookIfWritableLimitReached() {
|
|
const writtenLimit = Math.max(0, bookPaginationState.writtenPageLimit || 0);
|
|
while (writtenLimit >= getWritablePageLimit() && bookPageCount < PROCEDURAL_BOOK.PAGE_COUNT_MAX) {
|
|
bookPageCount = snapProceduralPageCount(bookPageCount + PROCEDURAL_BOOK.PAGE_COUNT_STEP);
|
|
}
|
|
}
|
|
|
|
function setBookPageCount(value) {
|
|
const nextPageCount = snapProceduralPageCount(value);
|
|
if (!Number.isFinite(nextPageCount)) return;
|
|
bookPageCount = Math.max(nextPageCount, bookPageCount);
|
|
pageReserve = clampPageReserve(pageReserve, bookPageCount);
|
|
growBookIfWritableLimitReached();
|
|
buildBook();
|
|
notifyBookPageCountChanged();
|
|
syncBookControls();
|
|
window.WebGLBookPreferenceBridge?.updatePageCount?.(bookPageCount);
|
|
}
|
|
|
|
function setPageReserve(value) {
|
|
pageReserve = clampPageReserve(value, bookPageCount);
|
|
growBookIfWritableLimitReached();
|
|
buildBook();
|
|
notifyBookPageCountChanged();
|
|
syncBookControls();
|
|
window.WebGLBookPreferenceBridge?.updatePageReserve?.(pageReserve);
|
|
window.WebGLBookPreferenceBridge?.updatePageCount?.(bookPageCount);
|
|
}
|
|
|
|
function notifyBookPageCountChanged() {
|
|
document.dispatchEvent(new CustomEvent('webgl-book:page-count-changed', {
|
|
detail: {
|
|
pageCount: bookPageCount,
|
|
model: currentProceduralBookModel
|
|
}
|
|
}));
|
|
}
|
|
|
|
function stepReadingProgress(pageDelta) {
|
|
setReadingProgress(readingProgress + pageDelta / Math.max(1, bookPageCount));
|
|
}
|
|
|
|
function installBookControls() {
|
|
ensureBottomNavigation();
|
|
if (progressInput) {
|
|
progressInput.value = readingProgress.toFixed(3);
|
|
progressInput.addEventListener('input', () => setReadingProgress(progressInput.value));
|
|
}
|
|
if (pageCountInput) {
|
|
pageCountInput.min = String(PROCEDURAL_BOOK.PAGE_COUNT_MIN);
|
|
pageCountInput.max = String(PROCEDURAL_BOOK.PAGE_COUNT_MAX);
|
|
pageCountInput.step = String(PROCEDURAL_BOOK.PAGE_COUNT_STEP);
|
|
pageCountInput.value = String(bookPageCount);
|
|
pageCountInput.addEventListener('input', () => setBookPageCount(pageCountInput.value));
|
|
}
|
|
backwardButton?.addEventListener('click', () => startPageFlip(-1));
|
|
forwardButton?.addEventListener('click', () => startPageFlip(1));
|
|
fastBackwardButton?.addEventListener('click', () => startFastPageFlip(-1));
|
|
fastForwardButton?.addEventListener('click', () => startFastPageFlip(1));
|
|
syncBookControls();
|
|
}
|
|
|
|
function ensureBottomNavigation() {
|
|
if (bottomNavigation) return bottomNavigation;
|
|
const root = document.createElement('nav');
|
|
root.id = 'webgl_book_navigation';
|
|
root.setAttribute('aria-label', appInitialState.t?.('webgl.bookControls') || 'Book controls');
|
|
|
|
const makeButton = (id, label, icon) => {
|
|
const button = document.createElement('button');
|
|
button.id = id;
|
|
button.type = 'button';
|
|
button.className = 'webgl-book-nav-button';
|
|
button.setAttribute('aria-label', label);
|
|
button.title = label;
|
|
button.textContent = icon;
|
|
root.appendChild(button);
|
|
return button;
|
|
};
|
|
|
|
const startButton = makeButton('webgl_book_nav_start', appInitialState.t?.('webgl.returnToBeginning') || 'Return to beginning', '⏮');
|
|
const backButton = makeButton('webgl_book_nav_back', appInitialState.t?.('webgl.backward') || 'Backward', '◀');
|
|
const sliderWrap = document.createElement('div');
|
|
sliderWrap.className = 'webgl-book-nav-slider-wrap';
|
|
const minLabel = document.createElement('span');
|
|
minLabel.id = 'webgl_book_nav_min_label';
|
|
minLabel.className = 'webgl-book-nav-limit-label';
|
|
minLabel.textContent = '0';
|
|
const sliderTrack = document.createElement('div');
|
|
sliderTrack.className = 'webgl-book-nav-slider-track';
|
|
const pageLabel = document.createElement('output');
|
|
pageLabel.id = 'webgl_book_nav_page_label';
|
|
pageLabel.className = 'webgl-book-nav-page-label';
|
|
pageLabel.textContent = '0';
|
|
const maxLabel = document.createElement('span');
|
|
maxLabel.id = 'webgl_book_nav_max_label';
|
|
maxLabel.className = 'webgl-book-nav-limit-label';
|
|
maxLabel.textContent = String(bookPageCount);
|
|
const slider = document.createElement('input');
|
|
slider.id = 'webgl_book_nav_position';
|
|
slider.type = 'range';
|
|
slider.min = '0';
|
|
slider.step = '1';
|
|
slider.value = '0';
|
|
sliderTrack.appendChild(minLabel);
|
|
sliderTrack.appendChild(slider);
|
|
sliderTrack.appendChild(maxLabel);
|
|
sliderWrap.appendChild(sliderTrack);
|
|
sliderWrap.appendChild(pageLabel);
|
|
root.appendChild(sliderWrap);
|
|
const forwardButton = makeButton('webgl_book_nav_forward', appInitialState.t?.('webgl.forward') || 'Forward', '▶');
|
|
const endButton = makeButton('webgl_book_nav_end', appInitialState.t?.('webgl.goToEnd') || 'Go to end', '⏭');
|
|
|
|
startButton.addEventListener('click', () => navigateToPagePosition(0));
|
|
backButton.addEventListener('click', () => navigateByPageDelta(-1));
|
|
forwardButton.addEventListener('click', () => navigateByPageDelta(1));
|
|
endButton.addEventListener('click', () => navigateToPagePosition(maxVisitedPagePosition));
|
|
slider.addEventListener('input', () => {
|
|
const requested = Number(slider.value);
|
|
const clamped = Math.min(requested, maxVisitedPagePosition, getWritablePageLimit());
|
|
if (requested !== clamped) slider.value = String(clamped);
|
|
pageLabel.textContent = `${appInitialState.t?.('webgl.page') || 'Page'} ${clamped}`;
|
|
});
|
|
slider.addEventListener('change', () => navigateToPagePosition(Number(slider.value)));
|
|
|
|
document.body.appendChild(root);
|
|
bottomNavigation = {
|
|
root,
|
|
startButton,
|
|
backButton,
|
|
slider,
|
|
minLabel,
|
|
maxLabel,
|
|
pageLabel,
|
|
forwardButton,
|
|
endButton
|
|
};
|
|
return bottomNavigation;
|
|
}
|
|
|
|
function navigateByPageDelta(delta) {
|
|
const current = getCurrentPagePosition();
|
|
const next = Math.max(0, current + Math.sign(Number(delta || 0)));
|
|
return navigateToPagePosition(next);
|
|
}
|
|
|
|
function navigateToPagePosition(pagePosition) {
|
|
const writableLimit = getWritablePageLimit();
|
|
const targetPage = THREE.MathUtils.clamp(Math.round(Number(pagePosition || 0)), 0, Math.min(writableLimit, maxVisitedPagePosition));
|
|
const currentPage = getCurrentPagePosition();
|
|
if (targetPage === currentPage) {
|
|
syncBookControls();
|
|
return false;
|
|
}
|
|
const targetSpread = pageToSpreadIndex(targetPage);
|
|
const currentSpread = bookPaginationState.spreadIndex;
|
|
const spreadDelta = targetSpread - currentSpread;
|
|
if (Math.abs(spreadDelta) === 1) {
|
|
return startPageFlip(Math.sign(spreadDelta), { targetSpread });
|
|
}
|
|
return startFastPageFlip(Math.sign(spreadDelta), { targetSpread, skippedSpreads: Math.abs(spreadDelta) });
|
|
}
|
|
|
|
function syncBookControls() {
|
|
const busy = activeFlips.length > 0;
|
|
if (progressInput) progressInput.value = readingProgress.toFixed(3);
|
|
if (progressValue) progressValue.textContent = readingProgress.toFixed(2);
|
|
if (pageCountInput) pageCountInput.value = String(bookPageCount);
|
|
if (pageCountValue) pageCountValue.textContent = String(bookPageCount);
|
|
if (backwardButton) backwardButton.disabled = busy || !canPageFlip(-1);
|
|
if (fastBackwardButton) fastBackwardButton.disabled = busy || !canPageFlip(-1);
|
|
if (forwardButton) forwardButton.disabled = busy || !canPageFlip(1);
|
|
if (fastForwardButton) fastForwardButton.disabled = busy || !canPageFlip(1);
|
|
syncBottomNavigation();
|
|
}
|
|
|
|
function syncBottomNavigation() {
|
|
if (!bottomNavigation) return;
|
|
const currentPage = getCurrentPagePosition();
|
|
const writableLimit = getWritablePageLimit();
|
|
const navigableLimit = Math.min(maxVisitedPagePosition, writableLimit);
|
|
const reservedStart = Math.max(0, writableLimit);
|
|
bottomNavigation.slider.max = String(Math.max(0, bookPageCount));
|
|
bottomNavigation.slider.value = String(Math.min(currentPage, navigableLimit));
|
|
bottomNavigation.minLabel.textContent = '0';
|
|
bottomNavigation.maxLabel.textContent = String(bookPageCount);
|
|
bottomNavigation.pageLabel.textContent = `${appInitialState.t?.('webgl.page') || 'Page'} ${Math.min(currentPage, navigableLimit)}`;
|
|
bottomNavigation.root.style.setProperty('--book-nav-position', `${bookPageCount > 0 ? currentPage / bookPageCount : 0}`);
|
|
bottomNavigation.root.style.setProperty('--book-nav-written', `${bookPageCount > 0 ? maxVisitedPagePosition / bookPageCount : 0}`);
|
|
bottomNavigation.root.style.setProperty('--book-nav-reserve-start', `${bookPageCount > 0 ? reservedStart / bookPageCount : 1}`);
|
|
bottomNavigation.root.dataset.bookSize = String(bookPageCount);
|
|
bottomNavigation.root.dataset.pageReserve = String(pageReserve);
|
|
bottomNavigation.startButton.disabled = activeFlips.length > 0 || currentPage <= 0;
|
|
bottomNavigation.backButton.disabled = activeFlips.length > 0 || currentPage <= 0;
|
|
bottomNavigation.forwardButton.disabled = activeFlips.length > 0 || currentPage >= navigableLimit;
|
|
bottomNavigation.endButton.disabled = activeFlips.length > 0 || currentPage >= navigableLimit;
|
|
}
|
|
|
|
function handlePageTextureRecords(event) {
|
|
const detail = normalizePageTextureRecordDetail(event.detail || {});
|
|
if (detail.pageMeta) {
|
|
currentPageMeta = normalizePageMetaPair(detail.pageMeta, currentPageMeta);
|
|
}
|
|
markPageTextureTiming('handlePageTextureRecords:start', {
|
|
hasLeft: Boolean(detail.left),
|
|
hasRight: Boolean(detail.right),
|
|
revealSides: Object.keys(detail.reveal || {}),
|
|
phase: detail.phase || 'activate',
|
|
pageMeta: currentPageMeta
|
|
});
|
|
const leftReveal = attachRevealPageMeta(detail.reveal?.left, currentPageMeta.left || null);
|
|
const rightReveal = attachRevealPageMeta(detail.reveal?.right, currentPageMeta.right || null);
|
|
if (detail.phase === 'prepare') {
|
|
if (detail.left) {
|
|
const texture = preloadPageTexture('left', detail.left, leftReveal, currentPageMeta.left);
|
|
pageTextureStore?.rememberResidentTexture?.(currentPageMeta.left, texture, detail.left, true);
|
|
} else if (currentPageMeta.left?.kind === 'blank') {
|
|
pageTextureStore?.rememberResidentTexture?.(currentPageMeta.left, getBlankPageTexture(), null, false);
|
|
}
|
|
if (detail.right) {
|
|
const texture = preloadPageTexture('right', detail.right, rightReveal, currentPageMeta.right);
|
|
pageTextureStore?.rememberResidentTexture?.(currentPageMeta.right, texture, detail.right, true);
|
|
} else if (currentPageMeta.right?.kind === 'blank') {
|
|
pageTextureStore?.rememberResidentTexture?.(currentPageMeta.right, getBlankPageTexture(), null, false);
|
|
}
|
|
markPageTextureTiming('handlePageTextureRecords:prepare:end');
|
|
return;
|
|
}
|
|
if (detail.left) {
|
|
if (leftReveal) {
|
|
beginPageReveal('left', detail.left, leftReveal);
|
|
} else {
|
|
uploadPageTextureDirect('left', detail.left, currentPageMeta.left);
|
|
}
|
|
}
|
|
if (detail.right) {
|
|
if (rightReveal) {
|
|
beginPageReveal('right', detail.right, rightReveal);
|
|
} else {
|
|
uploadPageTextureDirect('right', detail.right, currentPageMeta.right);
|
|
}
|
|
}
|
|
if (!detail.left && currentPageMeta.left?.kind === 'blank') {
|
|
applyExplicitBlankPageTexture('left', currentPageMeta.left, 'page-texture-records');
|
|
}
|
|
if (!detail.right && currentPageMeta.right?.kind === 'blank') {
|
|
applyExplicitBlankPageTexture('right', currentPageMeta.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').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 pagination = window.moduleRegistry?.getModule?.('book-pagination') || null;
|
|
const spread = typeof pagination?.getSpread === 'function'
|
|
? pagination.getSpread(spreadIndex)
|
|
: Array.isArray(pagination?.spreads)
|
|
? pagination.spreads[spreadIndex]
|
|
: null;
|
|
return spread?.pageMeta?.[side] || 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 !== false
|
|
});
|
|
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)));
|
|
const windowMap = await prewarmNavigationTextureWindow('flip-prewarm', { targetSpread: nextSpread });
|
|
const current = windowMap?.[currentSpread] || await prewarmSpreadTextures(currentSpread);
|
|
const next = windowMap?.[nextSpread] || await prewarmSpreadTextures(nextSpread);
|
|
return {
|
|
current,
|
|
next
|
|
};
|
|
}
|
|
|
|
function 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;
|
|
} 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,
|
|
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('revealUpload:end', { side });
|
|
}
|
|
|
|
function applyPendingPageReveal(side, shader = getPageRevealShader(side)) {
|
|
const material = side === 'left' ? materials.leftPage : materials.rightPage;
|
|
const revealDetail = material?.userData?.pendingPageReveal;
|
|
if (!revealDetail || !shader?.uniforms) return false;
|
|
applyPageRevealRegions(shader, revealDetail.lineRects || []);
|
|
shader.uniforms.bookRevealActive.value = 1;
|
|
shader.uniforms.bookRevealElapsedMs.value = 0;
|
|
const baseTexture = pageRevealState[side]?.baseTexture;
|
|
if (shader.uniforms.bookRevealBaseMap) shader.uniforms.bookRevealBaseMap.value = baseTexture || (side === 'left' ? leftTexture : rightTexture);
|
|
if (shader.uniforms.bookRevealUseBaseMap) shader.uniforms.bookRevealUseBaseMap.value = baseTexture ? 1 : 0;
|
|
document.documentElement.dataset.webglRevealDebug = JSON.stringify({
|
|
side,
|
|
blockIds: pageRevealState[side]?.blockIds || revealDetail.blockIds || [],
|
|
regionCount: Array.isArray(revealDetail.lineRects) ? revealDetail.lineRects.length : 0,
|
|
shaderReady: true,
|
|
started: pageRevealState[side]?.startedAt != null
|
|
});
|
|
delete material.userData.pendingPageReveal;
|
|
return true;
|
|
}
|
|
|
|
function applyPageRevealRegions(shader, regions = []) {
|
|
const rectUniforms = shader.uniforms.bookRevealRegionRects.value;
|
|
const timingUniforms = shader.uniforms.bookRevealRegionTimings.value;
|
|
const source = Array.isArray(regions) ? regions : [];
|
|
if (source.length > maxRevealRegions) {
|
|
throw new Error(`WebGL reveal region count ${source.length} exceeds architectural maximum ${maxRevealRegions}`);
|
|
}
|
|
shader.uniforms.bookRevealRegionCount.value = source.length;
|
|
source.forEach((region, index) => {
|
|
const rect = region.rect || {};
|
|
const timing = region.timing || {};
|
|
const delay = Math.max(0, Number(timing.delay || 0));
|
|
const duration = Math.max(1, Number(timing.duration || 1));
|
|
const x = THREE.MathUtils.clamp(Number(rect.x || 0), 0, 1);
|
|
const y = THREE.MathUtils.clamp(Number(rect.y || 0), 0, 1);
|
|
const width = THREE.MathUtils.clamp(Number(rect.width || 0), 0, 1);
|
|
const height = THREE.MathUtils.clamp(Number(rect.height || 0), 0, 1);
|
|
rectUniforms[index].set(
|
|
x,
|
|
THREE.MathUtils.clamp(1 - y - height, 0, 1),
|
|
Math.max(0.0001, width),
|
|
Math.max(0.0001, height)
|
|
);
|
|
timingUniforms[index].set(
|
|
delay,
|
|
duration,
|
|
0,
|
|
0
|
|
);
|
|
});
|
|
for (let index = source.length; index < maxRevealRegions; index += 1) {
|
|
rectUniforms[index].set(0, 0, 0, 0);
|
|
timingUniforms[index].set(0, 1, 0, 0);
|
|
}
|
|
}
|
|
|
|
function getPageRevealShader(side) {
|
|
const material = side === 'left' ? materials.leftPage : materials.rightPage;
|
|
return material?.userData?.bookRevealShader || null;
|
|
}
|
|
|
|
function getRevealDebugState() {
|
|
return ['left', 'right'].reduce((state, side) => {
|
|
const shader = getPageRevealShader(side);
|
|
const uniforms = shader?.uniforms || {};
|
|
state[side] = {
|
|
active: Number(uniforms.bookRevealActive?.value || 0),
|
|
elapsedMs: Number(uniforms.bookRevealElapsedMs?.value || 0),
|
|
visualElapsedMs: Number(pageRevealState[side]?.visualElapsedMs || 0),
|
|
regionCount: Number(uniforms.bookRevealRegionCount?.value || 0),
|
|
usesBaseTexture: Number(uniforms.bookRevealUseBaseMap?.value || 0),
|
|
fastForwarding: pageRevealState[side]?.fastForwarding === true,
|
|
started: pageRevealState[side]?.startedAt != null,
|
|
pendingStart: pageRevealState[side]?.pendingStart === true,
|
|
durationMs: Number(pageRevealState[side]?.durationMs || 0),
|
|
blockIds: pageRevealState[side]?.blockIds || []
|
|
};
|
|
return state;
|
|
}, {});
|
|
}
|
|
|
|
function clearPageReveal(side, reason = 'clear') {
|
|
const previousState = pageRevealState[side];
|
|
pageRevealClearLog.push({
|
|
side,
|
|
reason,
|
|
at: performance.now(),
|
|
state: previousState ? {
|
|
started: previousState.startedAt != null,
|
|
pendingStart: previousState.pendingStart === true,
|
|
visualElapsedMs: previousState.visualElapsedMs || 0,
|
|
durationMs: previousState.durationMs,
|
|
blockIds: previousState.blockIds || []
|
|
} : null
|
|
});
|
|
if (pageRevealClearLog.length > 40) pageRevealClearLog.splice(0, pageRevealClearLog.length - 40);
|
|
document.documentElement.dataset.webglRevealClearLog = JSON.stringify(pageRevealClearLog);
|
|
pageRevealState[side] = null;
|
|
const shader = getPageRevealShader(side);
|
|
if (shader?.uniforms?.bookRevealActive) {
|
|
shader.uniforms.bookRevealActive.value = 0;
|
|
shader.uniforms.bookRevealElapsedMs.value = completedRevealElapsedMs;
|
|
shader.uniforms.bookRevealRegionCount.value = 0;
|
|
if (shader.uniforms.bookRevealUseBaseMap) shader.uniforms.bookRevealUseBaseMap.value = 0;
|
|
}
|
|
previousState?.baseTexture?.dispose?.();
|
|
}
|
|
|
|
function startPageRevealForBlock(blockId) {
|
|
const id = String(blockId ?? '');
|
|
if (!id) return;
|
|
if (!activeRevealBlockStarts.has(id)) activeRevealBlockStarts.set(id, performance.now());
|
|
if (activeFlips.length > 0) {
|
|
pendingRevealStartBlockIds.add(id);
|
|
markPageTextureTiming('revealStart:deferred-for-flip', {
|
|
blockId: id,
|
|
activeFlips: activeFlips.length
|
|
});
|
|
return;
|
|
}
|
|
['left', 'right'].forEach((side) => {
|
|
const state = pageRevealState[side];
|
|
if (!state || state.startedAt != null) return;
|
|
if (!state.blockIds.map(value => String(value)).includes(id)) return;
|
|
state.pendingStart = true;
|
|
state.startedAt = activeRevealBlockStarts.get(id) || performance.now();
|
|
const shader = getPageRevealShader(side);
|
|
if (shader?.uniforms?.bookRevealElapsedMs) shader.uniforms.bookRevealElapsedMs.value = 0;
|
|
});
|
|
}
|
|
|
|
function fastForwardPageReveals(blockIds = []) {
|
|
const ids = new Set((Array.isArray(blockIds) ? blockIds : []).map(value => String(value)));
|
|
['left', 'right'].forEach((side) => {
|
|
const state = pageRevealState[side];
|
|
if (!state) return;
|
|
const matches = ids.size === 0 || state.blockIds.some(blockId => ids.has(String(blockId)));
|
|
if (!matches) return;
|
|
state.fastForwarding = true;
|
|
state.fastForwardStartedAt = performance.now();
|
|
state.fastForwardStartElapsedMs = Math.max(0, Number(state.visualElapsedMs || 0));
|
|
state.fastForwardDurationMs = 260;
|
|
});
|
|
}
|
|
|
|
function updatePageRevealAnimations(now) {
|
|
if (activeFlips.length > 0) {
|
|
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 (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 = await prewarmFlipTextures(direction, targetSpread);
|
|
return startPageFlipPrepared(direction, {
|
|
...options,
|
|
targetSpread,
|
|
prewarm
|
|
});
|
|
}
|
|
|
|
function startPageFlipPrepared(direction, options = {}) {
|
|
if (activeFlips.length || !currentProceduralBookModel) return false;
|
|
if (!options.force && !canPageFlip(direction)) return false;
|
|
const flip = createPageFlip(direction, performance.now(), normalFlipDuration);
|
|
if (!flip) return false;
|
|
flip.targetSpread = Number.isFinite(Number(options.targetSpread)) ? Math.max(0, Math.round(Number(options.targetSpread))) : null;
|
|
if (!prepareStaticPageForFlip(flip, options.prewarm || null)) {
|
|
return false;
|
|
}
|
|
pendingRightPageFlip = false;
|
|
pendingRightPageFlipAutoplay = false;
|
|
delete document.documentElement.dataset.webglPendingPageFlip;
|
|
activeFlips.push(flip);
|
|
setPageFlipActiveFlag();
|
|
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 = 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';
|
|
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 = currentPageMeta?.[sourceSide] || getPaginationPageMeta(spreadPageIndices(bookPaginationState.spreadIndex)[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;
|
|
}
|
|
materials.flipPageSurface.map = sourceTexture;
|
|
materials.flipPageBackSurface.map = backTexture || getBlankPageTexture();
|
|
materials.flipPageSurface.normalMap = materials.pageSurface.normalMap;
|
|
materials.flipPageBackSurface.normalMap = materials.pageSurface.normalMap;
|
|
materials.flipPageSurface.roughnessMap = materials.pageSurface.roughnessMap;
|
|
materials.flipPageBackSurface.roughnessMap = materials.pageSurface.roughnessMap;
|
|
materials.flipPageSurface.needsUpdate = true;
|
|
materials.flipPageBackSurface.needsUpdate = true;
|
|
flip.sourceTexture = sourceTexture;
|
|
flip.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())
|
|
};
|
|
if (flip.direction > 0) {
|
|
const blankTexture = getBlankPageTexture();
|
|
if (blankTexture && materials.rightPage.map !== blankTexture) {
|
|
clearPageReveal('right', 'page-flip-start');
|
|
materials.rightPage.map = blankTexture;
|
|
materials.rightPage.needsUpdate = true;
|
|
}
|
|
} else if (flip.direction < 0) {
|
|
const blankTexture = getBlankPageTexture();
|
|
if (blankTexture && materials.leftPage.map !== blankTexture) {
|
|
clearPageReveal('left', 'page-flip-start');
|
|
materials.leftPage.map = blankTexture;
|
|
materials.leftPage.needsUpdate = true;
|
|
}
|
|
}
|
|
markPageTextureTiming('flipTexturePreflight:ready', {
|
|
...lastFlipTexturePreflight,
|
|
usedResidentBackTexture: Boolean(backTexture && backTexture !== getBlankPageTexture())
|
|
});
|
|
return true;
|
|
}
|
|
|
|
function resolveCurrentFlipSourceTexture(side) {
|
|
const pageMeta = currentPageMeta?.[side] || null;
|
|
if (pageMeta?.kind === 'blank') return getBlankPageTexture();
|
|
const resident = pageTextureStore?.getResidentTextureForMeta?.(pageMeta);
|
|
if (resident) return resident;
|
|
const material = side === 'left' ? materials.leftPage : materials.rightPage;
|
|
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 currentPage = getCurrentPagePosition();
|
|
const maxNavigablePage = Math.min(maxVisitedPagePosition, getWritablePageLimit());
|
|
if (direction > 0) return currentPage < maxNavigablePage;
|
|
return currentPage > 0;
|
|
}
|
|
|
|
function handleRevealCommittedForPageFlip(detail = {}) {
|
|
if (window.BookPlaybackTimeline?.ownsPageFlipCommit === true) return;
|
|
if (detail.side !== 'right' || detail.pageFlipAfterReveal !== true) return;
|
|
if (activeFlips.length > 0 || pendingRightPageFlip) return;
|
|
if (isChoiceAwaitingPlayer()) return;
|
|
const autoplayFlip = isTtsPlaybackActive();
|
|
pendingRightPageFlip = true;
|
|
pendingRightPageFlipAutoplay = autoplayFlip;
|
|
document.documentElement.dataset.webglPendingPageFlip = 'right';
|
|
if (autoplayFlip) {
|
|
tryStartPendingRightPageFlip('tts-active');
|
|
}
|
|
}
|
|
|
|
async function tryStartPendingRightPageFlip(reason = 'pending', options = {}) {
|
|
if (!pendingRightPageFlip || activeFlips.length > 0 || isChoiceAwaitingPlayer()) return false;
|
|
if (!options.force && !pendingRightPageFlipAutoplay) return false;
|
|
const targetSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)) + 1);
|
|
const flipped = await startPageFlip(1, {
|
|
force: options.force === true || pendingRightPageFlipAutoplay,
|
|
reason,
|
|
targetSpread
|
|
});
|
|
if (flipped) {
|
|
pendingRightPageFlip = false;
|
|
pendingRightPageFlipAutoplay = false;
|
|
delete document.documentElement.dataset.webglPendingPageFlip;
|
|
}
|
|
return flipped;
|
|
}
|
|
|
|
function isChoiceAwaitingPlayer() {
|
|
return document.documentElement.dataset.choiceAwaiting === 'true'
|
|
|| document.body?.dataset?.choiceAwaiting === 'true'
|
|
|| Boolean(document.querySelector('#choice_menu:not([hidden]) .choice, #choice_menu.visible .choice'));
|
|
}
|
|
|
|
function isTtsPlaybackActive() {
|
|
const coordinator = window.moduleRegistry?.getModule?.('playback-coordinator') || window.PlaybackCoordinator || null;
|
|
return Boolean(coordinator?.isPlaying || coordinator?.state === 'playing' || document.documentElement.dataset.ttsPlaying === 'true');
|
|
}
|
|
|
|
function topVisibleLine(side) {
|
|
const sideLines = currentProceduralBookModel.lines
|
|
.filter((line) => line.side === side)
|
|
.sort((a, b) => side < 0 ? a.t - b.t : b.t - a.t);
|
|
return sideLines[sideLines.length - 1] ?? null;
|
|
}
|
|
|
|
function updateActiveFlips(now) {
|
|
if (!activeFlips.length || !currentProceduralBookModel) return;
|
|
const completed = [];
|
|
activeFlips.forEach((flip) => {
|
|
const elapsed = (now - flip.startTime) / flip.duration;
|
|
if (elapsed < 0) return;
|
|
const t = THREE.MathUtils.clamp(elapsed, 0, 1);
|
|
const surface = buildFlippingPageSurface(flip.sourceLine, flip.destinationLine, flip.direction, easeInOutCubic(t), flip.pageOffset);
|
|
setActivePageGeometry(flip, surface);
|
|
if (!flip.spreadAdvanced && t >= 0.82) {
|
|
flip.spreadAdvanced = true;
|
|
const targetSpread = Number.isFinite(Number(flip.targetSpread))
|
|
? Math.max(0, Math.round(Number(flip.targetSpread)))
|
|
: null;
|
|
document.dispatchEvent(new CustomEvent('webgl-book:page-flip-near-end', {
|
|
detail: {
|
|
direction: flip.direction,
|
|
sourceSide: flip.sourcePageSide || (flip.direction > 0 ? 'right' : 'left'),
|
|
targetSpread
|
|
}
|
|
}));
|
|
}
|
|
if (t >= 1) completed.push(flip);
|
|
});
|
|
completed.forEach((flip) => finishActiveFlip(flip));
|
|
}
|
|
|
|
function buildFlippingPageSurface(sourceLine, destinationLine, direction, t, pageOffset = 0) {
|
|
const widthSegments = sourceLine.points.length - 1;
|
|
const depthSegments = 18;
|
|
const zFront = currentProceduralBookModel.pageDepth * 0.5;
|
|
const zBack = -currentProceduralBookModel.pageDepth * 0.5;
|
|
if (t <= 0) return createRestingPageSurface(sourceLine.points, depthSegments, zFront, zBack);
|
|
if (t >= 1) return createRestingPageSurface(destinationLine.points, depthSegments, zFront, zBack);
|
|
|
|
const anchor = {
|
|
x: THREE.MathUtils.lerp(sourceLine.anchor.x, destinationLine.anchor.x, t),
|
|
y: THREE.MathUtils.lerp(sourceLine.anchor.y, destinationLine.anchor.y, t)
|
|
};
|
|
const sourceSide = direction > 0 ? 1 : -1;
|
|
const startAngle = sourceSide > 0 ? 0 : Math.PI;
|
|
const baseAngle = startAngle + direction * Math.PI * t;
|
|
const lift = Math.sin(Math.PI * t);
|
|
const curlStrength = direction * 0.48 * lift;
|
|
const widthDistances = cumulativeLineLengths(sourceLine.points);
|
|
const surface = [];
|
|
for (let widthIndex = 0; widthIndex <= widthSegments; widthIndex += 1) {
|
|
const u = widthIndex / widthSegments;
|
|
const radius = widthDistances[widthIndex];
|
|
const row = [];
|
|
for (let depthIndex = 0; depthIndex <= depthSegments; depthIndex += 1) {
|
|
const v = depthIndex / depthSegments;
|
|
const z = THREE.MathUtils.lerp(zFront, zBack, v);
|
|
const depthWave = (v - 0.5) * 0.22 * lift * (0.15 + u * 0.85);
|
|
const curl = curlStrength * Math.sin(Math.PI * u) + direction * depthWave;
|
|
const angle = baseAngle + curl;
|
|
const stackPoint = interpolatePagePoint(sourceLine.points, destinationLine.points, widthIndex, t);
|
|
const flyingX = anchor.x + Math.cos(angle) * radius;
|
|
const relaxedY = THREE.MathUtils.lerp(stackPoint.y, anchor.y + Math.sin(angle) * radius, lift);
|
|
const point = {
|
|
x: THREE.MathUtils.lerp(stackPoint.x, flyingX, lift),
|
|
y: relaxedY + pageOffset + 0.055 * lift * Math.sin(Math.PI * u),
|
|
z
|
|
};
|
|
keepFlippingSurfacePointAboveStacks(point, lift);
|
|
row.push(point);
|
|
}
|
|
surface.push(row);
|
|
}
|
|
return surface;
|
|
}
|
|
|
|
function cumulativeLineLengths(points) {
|
|
const lengths = [0];
|
|
for (let index = 1; index < points.length; index += 1) {
|
|
const previous = points[index - 1];
|
|
const point = points[index];
|
|
lengths[index] = lengths[index - 1] + Math.hypot(point.x - previous.x, point.y - previous.y);
|
|
}
|
|
return lengths;
|
|
}
|
|
|
|
function createRestingPageSurface(points, depthSegments, zFront, zBack) {
|
|
return points.map((point) => {
|
|
const row = [];
|
|
for (let depthIndex = 0; depthIndex <= depthSegments; depthIndex += 1) {
|
|
row.push({
|
|
x: point.x,
|
|
y: point.y,
|
|
z: THREE.MathUtils.lerp(zFront, zBack, depthIndex / depthSegments)
|
|
});
|
|
}
|
|
return row;
|
|
});
|
|
}
|
|
|
|
function interpolatePagePoint(sourcePoints, destinationPoints, index, t) {
|
|
const source = sourcePoints[index];
|
|
const destination = destinationPoints[index];
|
|
return {
|
|
x: THREE.MathUtils.lerp(source.x, destination.x, t),
|
|
y: THREE.MathUtils.lerp(source.y, destination.y, t)
|
|
};
|
|
}
|
|
|
|
function keepFlippingSurfacePointAboveStacks(point, lift) {
|
|
const envelopeY = stackEnvelopeYAtX(point.x);
|
|
if (envelopeY === null) return;
|
|
const clearance = 0.016 + lift * 0.045;
|
|
point.y = Math.max(point.y, envelopeY + clearance);
|
|
}
|
|
|
|
function stackEnvelopeYAtX(x) {
|
|
let envelope = null;
|
|
currentProceduralBookModel.lines.forEach((line) => {
|
|
const y = lineYAtX(line.points, x);
|
|
if (y === null) return;
|
|
envelope = envelope === null ? y : Math.max(envelope, y);
|
|
});
|
|
return envelope;
|
|
}
|
|
|
|
function lineYAtX(points, x) {
|
|
let y = null;
|
|
for (let index = 0; index < points.length - 1; index += 1) {
|
|
const a = points[index];
|
|
const b = points[index + 1];
|
|
const minX = Math.min(a.x, b.x) - 0.00001;
|
|
const maxX = Math.max(a.x, b.x) + 0.00001;
|
|
if (x < minX || x > maxX) continue;
|
|
const span = b.x - a.x;
|
|
const segmentY = Math.abs(span) < 0.00001
|
|
? Math.max(a.y, b.y)
|
|
: THREE.MathUtils.lerp(a.y, b.y, (x - a.x) / span);
|
|
y = y === null ? segmentY : Math.max(y, segmentY);
|
|
}
|
|
return y;
|
|
}
|
|
|
|
function setActivePageGeometry(flip, surface) {
|
|
if (!flip.mesh) {
|
|
const geometry = createFlippingPageGeometry(surface, flip.direction);
|
|
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);
|
|
flip.mesh.geometry.dispose();
|
|
flip.mesh.geometry = geometry;
|
|
}
|
|
}
|
|
|
|
function createFlippingPageGeometry(surface, direction = 1) {
|
|
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 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 = widthSegments <= 0 ? 0 : widthIndex / widthSegments;
|
|
rowPoints.forEach((point, depthIndex) => {
|
|
const v = depthSegments <= 0 ? 0 : depthIndex / depthSegments;
|
|
topRow.push(push(point, pageThickness, pageUvForSide(sourceSide, u, v)));
|
|
bottomRow.push(push(point, 0, pageUvForSide(targetSide, 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, 0);
|
|
geometry.addGroup(topIndices.length, bottomIndices.length, 1);
|
|
geometry.addGroup(topIndices.length + bottomIndices.length, wallIndices.length, 2);
|
|
geometry.computeVertexNormals();
|
|
return geometry;
|
|
|
|
function addWall(topA, topB, bottomA, bottomB) {
|
|
wallIndices.push(topA, bottomA, topB, topB, bottomA, bottomB);
|
|
}
|
|
}
|
|
|
|
function pageUvForSide(side, u, v) {
|
|
return {
|
|
x: side < 0 ? 1 - u : u,
|
|
y: 1 - 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();
|
|
}
|
|
document.dispatchEvent(new CustomEvent('webgl-book:page-flip-finished', {
|
|
detail: {
|
|
direction: flip.direction,
|
|
sourceSide: flip.sourcePageSide || (flip.direction > 0 ? 'right' : 'left'),
|
|
targetSpread: Number.isFinite(Number(flip.targetSpread)) ? Math.max(0, Math.round(Number(flip.targetSpread))) : null
|
|
}
|
|
}));
|
|
flushPendingRevealStarts();
|
|
if (flip.commitBundleOnFinish) {
|
|
if (Number.isFinite(Number(flip.targetSpread))) {
|
|
syncBookControls();
|
|
} else {
|
|
shiftReadingProgressByBundle(flip.direction);
|
|
}
|
|
return;
|
|
}
|
|
if (!flip.countAsPending) {
|
|
syncBookControls();
|
|
return;
|
|
}
|
|
|
|
pendingPageFlips += flip.direction;
|
|
if (Math.abs(pendingPageFlips) >= 10) {
|
|
const commitDirection = Math.sign(pendingPageFlips);
|
|
pendingPageFlips -= commitDirection * 10;
|
|
shiftReadingProgressByBundle(commitDirection);
|
|
return;
|
|
}
|
|
syncBookControls();
|
|
}
|
|
|
|
function shiftReadingProgressByBundle(direction) {
|
|
const step = 1 / Math.max(1, currentProceduralBookModel.bundleCount - 1);
|
|
setReadingProgress(readingProgress + direction * step);
|
|
}
|
|
|
|
function clearActiveFlips() {
|
|
activeFlips.forEach(removeFlipMesh);
|
|
activeFlips = [];
|
|
}
|
|
|
|
function removeFlipMesh(flip) {
|
|
if (!flip.mesh) return;
|
|
aoExcludedObjects.delete(flip.mesh);
|
|
book.remove(flip.mesh);
|
|
flip.mesh.geometry.dispose();
|
|
flip.mesh = null;
|
|
}
|
|
|
|
function easeInOutCubic(t) {
|
|
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) * 0.5;
|
|
}
|
|
|
|
function makeBox(width, height, depth, material) {
|
|
const mesh = new THREE.Mesh(new THREE.BoxGeometry(width, height, depth), material);
|
|
mesh.castShadow = false;
|
|
mesh.receiveShadow = false;
|
|
return mesh;
|
|
}
|
|
|
|
function progressPageTopY(side, thickness, u, v) {
|
|
const boundLift = 0.055 * Math.pow(1 - u, 2.2);
|
|
const foreEdgeSettle = -0.018 * Math.pow(u, 1.45);
|
|
const pageCrown = 0.014 * Math.sin(Math.PI * u) * (0.25 + 0.75 * Math.sin(Math.PI * v));
|
|
const foreEdgeIrregularity = 0.005 * Math.sin(v * Math.PI * 7.0 + side * 1.8) * Math.pow(u, 2.0);
|
|
return thickness + boundLift + foreEdgeSettle + pageCrown + foreEdgeIrregularity;
|
|
}
|
|
|
|
function pageSheetY(side, thickness, u, v) {
|
|
const gutterLift = 0.066 * Math.pow(1 - u, 2.05);
|
|
const edgeFall = -0.017 * Math.pow(u, 1.65);
|
|
const centerSag = -0.019 * Math.sin(Math.PI * v) * Math.sin(Math.PI * u);
|
|
const crown = 0.012 * Math.sin(Math.PI * u) * (0.35 + 0.65 * Math.sin(Math.PI * v));
|
|
return thickness + gutterLift + edgeFall + centerSag + crown;
|
|
}
|
|
|
|
function pageSheetPosition(side, width, height, thickness, gutterW, u, v, yOffset = 0) {
|
|
const outward = u * width;
|
|
const pageX = side * (gutterW + outward);
|
|
const ripple = 0.004 * Math.sin(v * Math.PI * 4 + side * 0.7) * (1 - Math.abs(u - 0.5));
|
|
return new THREE.Vector3(pageX, pageSheetY(side, thickness, u, v) + yOffset, (v - 0.5) * height + ripple);
|
|
}
|
|
|
|
function createVisiblePageGeometry(side, width, height, stackThickness, sheetThickness, gutterW) {
|
|
const columns = 36;
|
|
const rows = 42;
|
|
const positions = [];
|
|
const uvs = [];
|
|
const indices = [];
|
|
|
|
const pushVertex = (u, v, yOffset) => {
|
|
const point = pageSheetPosition(side, width, height, stackThickness, gutterW, u, v, yOffset);
|
|
positions.push(point.x, point.y, point.z);
|
|
uvs.push(side < 0 ? 1 - u : u, 1 - v);
|
|
};
|
|
|
|
for (let y = 0; y <= rows; y += 1) {
|
|
const v = y / rows;
|
|
for (let x = 0; x <= columns; x += 1) {
|
|
const u = x / columns;
|
|
pushVertex(u, v, sheetThickness * 0.5);
|
|
}
|
|
}
|
|
const bottomStart = positions.length / 3;
|
|
for (let y = 0; y <= rows; y += 1) {
|
|
const v = y / rows;
|
|
for (let x = 0; x <= columns; x += 1) {
|
|
const u = x / columns;
|
|
pushVertex(u, v, -sheetThickness * 0.5);
|
|
}
|
|
}
|
|
|
|
for (let y = 0; y < rows; y += 1) {
|
|
for (let x = 0; x < columns; x += 1) {
|
|
const a = y * (columns + 1) + x;
|
|
const b = a + 1;
|
|
const c = a + columns + 1;
|
|
const d = c + 1;
|
|
indices.push(a, c, b, b, c, d);
|
|
indices.push(bottomStart + a, bottomStart + b, bottomStart + c, bottomStart + b, bottomStart + d, bottomStart + c);
|
|
}
|
|
}
|
|
|
|
for (let y = 0; y < rows; y += 1) {
|
|
const topA = y * (columns + 1);
|
|
const topB = topA + columns + 1;
|
|
const bottomA = bottomStart + topA;
|
|
const bottomB = bottomStart + topB;
|
|
indices.push(topA, bottomA, topB, topB, bottomA, bottomB);
|
|
|
|
const outerA = y * (columns + 1) + columns;
|
|
const outerB = outerA + columns + 1;
|
|
const outerBottomA = bottomStart + outerA;
|
|
const outerBottomB = bottomStart + outerB;
|
|
indices.push(outerA, outerB, outerBottomA, outerB, outerBottomB, outerBottomA);
|
|
}
|
|
|
|
for (let x = 0; x < columns; x += 1) {
|
|
const headA = x;
|
|
const headB = x + 1;
|
|
const headBottomA = bottomStart + headA;
|
|
const headBottomB = bottomStart + headB;
|
|
indices.push(headA, headB, headBottomA, headB, headBottomB, headBottomA);
|
|
|
|
const tailA = rows * (columns + 1) + x;
|
|
const tailB = tailA + 1;
|
|
const tailBottomA = bottomStart + tailA;
|
|
const tailBottomB = bottomStart + tailB;
|
|
indices.push(tailA, tailBottomA, tailB, tailB, tailBottomA, tailBottomB);
|
|
}
|
|
|
|
const geometry = new THREE.BufferGeometry();
|
|
geometry.setIndex(indices);
|
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
|
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
|
|
geometry.computeVertexNormals();
|
|
return geometry;
|
|
}
|
|
|
|
function createProgressPageBlockGeometry(side, width, height, thickness, gutterW) {
|
|
const columns = 30;
|
|
const rows = 34;
|
|
const positions = [];
|
|
const uvs = [];
|
|
const indices = [];
|
|
const top = [];
|
|
const bottom = [];
|
|
|
|
const stackY = (u, v) => {
|
|
const gutterLift = 0.035 * Math.pow(1 - u, 1.7);
|
|
const edgeDrop = -0.018 * Math.pow(u, 1.25);
|
|
const pageCrown = 0.015 * Math.sin(Math.PI * u) * Math.sin(Math.PI * v);
|
|
return thickness * 0.5 + gutterLift + edgeDrop + pageCrown;
|
|
};
|
|
|
|
const push = (x, y, z, u, v) => {
|
|
const index = positions.length / 3;
|
|
positions.push(x, y, z);
|
|
uvs.push(u, v);
|
|
return index;
|
|
};
|
|
|
|
for (let y = 0; y <= rows; y += 1) {
|
|
const v = y / rows;
|
|
top[y] = [];
|
|
bottom[y] = [];
|
|
for (let x = 0; x <= columns; x += 1) {
|
|
const u = x / columns;
|
|
const px = side * (gutterW + u * width);
|
|
const pz = (v - 0.5) * height;
|
|
top[y][x] = push(px, progressPageTopY(side, thickness, u, v), pz, u, 1 - v);
|
|
bottom[y][x] = push(px, 0, pz, u, 1 - v);
|
|
}
|
|
}
|
|
|
|
for (let y = 0; y < rows; y += 1) {
|
|
for (let x = 0; x < columns; x += 1) {
|
|
indices.push(top[y][x], top[y + 1][x], top[y][x + 1], top[y][x + 1], top[y + 1][x], top[y + 1][x + 1]);
|
|
indices.push(bottom[y][x], bottom[y][x + 1], bottom[y + 1][x], bottom[y][x + 1], bottom[y + 1][x + 1], bottom[y + 1][x]);
|
|
}
|
|
}
|
|
|
|
for (let y = 0; y < rows; y += 1) {
|
|
indices.push(top[y][0], bottom[y][0], top[y + 1][0], top[y + 1][0], bottom[y][0], bottom[y + 1][0]);
|
|
indices.push(top[y][columns], top[y + 1][columns], bottom[y][columns], top[y + 1][columns], bottom[y + 1][columns], bottom[y][columns]);
|
|
}
|
|
for (let x = 0; x < columns; x += 1) {
|
|
indices.push(top[0][x], top[0][x + 1], bottom[0][x], top[0][x + 1], bottom[0][x + 1], bottom[0][x]);
|
|
indices.push(top[rows][x], bottom[rows][x], top[rows][x + 1], top[rows][x + 1], bottom[rows][x], bottom[rows][x + 1]);
|
|
}
|
|
|
|
const geometry = new THREE.BufferGeometry();
|
|
geometry.setIndex(indices);
|
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
|
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
|
|
geometry.computeVertexNormals();
|
|
return geometry;
|
|
}
|
|
|
|
function createPageEdgeSurfaceGeometry(side, width, height, thickness, gutterW) {
|
|
const rows = 44;
|
|
const layers = 7;
|
|
const positions = [];
|
|
const uvs = [];
|
|
const indices = [];
|
|
|
|
for (let y = 0; y <= layers; y += 1) {
|
|
const layer = y / layers;
|
|
for (let z = 0; z <= rows; z += 1) {
|
|
const v = z / rows;
|
|
const waviness = 0.012 * Math.sin(v * Math.PI * 5.0 + layer * 3.2);
|
|
const x = side * (gutterW + width + waviness);
|
|
const topY = progressPageTopY(side, thickness, 1, v);
|
|
positions.push(x, topY * layer, (v - 0.5) * height);
|
|
uvs.push(v, layer);
|
|
}
|
|
}
|
|
|
|
for (let y = 0; y < layers; y += 1) {
|
|
for (let z = 0; z < rows; z += 1) {
|
|
const a = y * (rows + 1) + z;
|
|
const b = a + 1;
|
|
const c = a + rows + 1;
|
|
const d = c + 1;
|
|
indices.push(a, c, b, b, c, d);
|
|
}
|
|
}
|
|
|
|
const geometry = new THREE.BufferGeometry();
|
|
geometry.setIndex(indices);
|
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
|
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
|
|
geometry.computeVertexNormals();
|
|
return geometry;
|
|
}
|
|
|
|
function createSpineGeometry(width, height, depth) {
|
|
const geometry = new THREE.CapsuleGeometry(width * 0.42, depth * 0.5, 5, 16);
|
|
geometry.rotateX(Math.PI / 2);
|
|
geometry.scale(1, height / width, 1);
|
|
return geometry;
|
|
}
|
|
|
|
function createGutterGeometry(width, height, depth) {
|
|
const geometry = new THREE.BoxGeometry(width, height, depth, 1, 1, 12);
|
|
const positions = geometry.attributes.position;
|
|
for (let i = 0; i < positions.count; i += 1) {
|
|
const x = positions.getX(i);
|
|
const z = positions.getZ(i);
|
|
const y = positions.getY(i);
|
|
const valley = -0.018 * (1 - Math.min(1, Math.abs(x) / (width * 0.5))) * (0.35 + 0.65 * Math.sin((z / depth + 0.5) * Math.PI));
|
|
positions.setY(i, y + valley);
|
|
}
|
|
positions.needsUpdate = true;
|
|
geometry.computeVertexNormals();
|
|
return geometry;
|
|
}
|
|
|
|
function createPageCanvas(side) {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = pageTextureWidth;
|
|
canvas.height = Math.round(pageTextureWidth * PROCEDURAL_BOOK.PAGE_DEPTH / PROCEDURAL_BOOK.PAGE_WIDTH);
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.fillStyle = '#fffaf0';
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
canvas.style.width = `${canvas.width}px`;
|
|
canvas.style.height = `${canvas.height}px`;
|
|
|
|
const shade = ctx.createLinearGradient(0, 0, canvas.width, 0);
|
|
shade.addColorStop(0, 'rgba(93, 55, 24, 0.10)');
|
|
shade.addColorStop(side === 'left' ? 0.85 : 0.15, 'rgba(255, 255, 255, 0)');
|
|
shade.addColorStop(1, 'rgba(85, 49, 21, 0.08)');
|
|
ctx.fillStyle = shade;
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
return canvas;
|
|
}
|
|
|
|
function createLeatherTextures() {
|
|
const size = 1024;
|
|
const colorCanvas = document.createElement('canvas');
|
|
const normalCanvas = document.createElement('canvas');
|
|
const roughnessCanvas = document.createElement('canvas');
|
|
colorCanvas.width = size;
|
|
colorCanvas.height = size;
|
|
normalCanvas.width = size;
|
|
normalCanvas.height = size;
|
|
roughnessCanvas.width = size;
|
|
roughnessCanvas.height = size;
|
|
const colorContext = colorCanvas.getContext('2d');
|
|
const normalContext = normalCanvas.getContext('2d');
|
|
const roughnessContext = roughnessCanvas.getContext('2d');
|
|
const colorImage = colorContext.createImageData(size, size);
|
|
const normalImage = normalContext.createImageData(size, size);
|
|
const roughnessImage = roughnessContext.createImageData(size, size);
|
|
const heightAt = (x, y) => {
|
|
const nx = x / size;
|
|
const ny = y / size;
|
|
const longGrain = Math.sin((nx * 24 + Math.sin(ny * 31.4159265359) * 0.18) * 6.28318530718);
|
|
const secondaryGrain = Math.sin((nx * 63 + ny * 9 + Math.sin(ny * 50.2654824574) * 0.1) * 6.28318530718);
|
|
const crossGrain = Math.sin((ny * 39 + Math.sin(nx * 18.8495559215) * 0.12) * 6.28318530718);
|
|
const poreA = Math.sin((nx * 137 + ny * 71) * 6.28318530718);
|
|
const poreB = Math.sin((nx * 97 - ny * 113) * 6.28318530718);
|
|
const pebble = Math.sin((nx * 181 + Math.sin(ny * 25.1327412287) * 0.22) * 6.28318530718) *
|
|
Math.sin((ny * 167 + Math.sin(nx * 37.6991118431) * 0.18) * 6.28318530718);
|
|
const pit = Math.max(0, 0.58 - Math.abs(poreA * poreB));
|
|
return longGrain * 0.22 + secondaryGrain * 0.16 + crossGrain * 0.1 + pebble * 0.18 - pit * 0.24;
|
|
};
|
|
|
|
for (let y = 0; y < size; y += 1) {
|
|
for (let x = 0; x < size; x += 1) {
|
|
const wrappedX = (x + size) % size;
|
|
const wrappedY = (y + size) % size;
|
|
const height = heightAt(wrappedX, wrappedY);
|
|
const grain = THREE.MathUtils.clamp(0.58 + height * 0.24, 0, 1);
|
|
const warm = 0.86 + 0.1 * Math.sin((x * 0.045 + y * 0.011)) + 0.04 * Math.sin((x * 0.009 - y * 0.031));
|
|
const index = (y * size + x) * 4;
|
|
colorImage.data[index] = Math.round(118 * grain * warm);
|
|
colorImage.data[index + 1] = Math.round(54 * grain * warm);
|
|
colorImage.data[index + 2] = Math.round(22 * grain);
|
|
colorImage.data[index + 3] = 255;
|
|
|
|
const hLeft = heightAt((x - 1 + size) % size, wrappedY);
|
|
const hRight = heightAt((x + 1) % size, wrappedY);
|
|
const hDown = heightAt(wrappedX, (y - 1 + size) % size);
|
|
const hUp = heightAt(wrappedX, (y + 1) % size);
|
|
const normal = new THREE.Vector3((hLeft - hRight) * 4.1, (hDown - hUp) * 4.1, 1).normalize();
|
|
normalImage.data[index] = Math.round((normal.x * 0.5 + 0.5) * 255);
|
|
normalImage.data[index + 1] = Math.round((normal.y * 0.5 + 0.5) * 255);
|
|
normalImage.data[index + 2] = Math.round((normal.z * 0.5 + 0.5) * 255);
|
|
normalImage.data[index + 3] = 255;
|
|
|
|
const fiberContrast = Math.abs(hLeft - hRight) + Math.abs(hDown - hUp);
|
|
const roughness = THREE.MathUtils.clamp(0.76 + height * 0.1 + fiberContrast * 1.4, 0.5, 0.96);
|
|
const roughnessByte = Math.round(roughness * 255);
|
|
roughnessImage.data[index] = roughnessByte;
|
|
roughnessImage.data[index + 1] = roughnessByte;
|
|
roughnessImage.data[index + 2] = roughnessByte;
|
|
roughnessImage.data[index + 3] = 255;
|
|
}
|
|
}
|
|
|
|
colorContext.putImageData(colorImage, 0, 0);
|
|
normalContext.putImageData(normalImage, 0, 0);
|
|
roughnessContext.putImageData(roughnessImage, 0, 0);
|
|
const colorTexture = new THREE.CanvasTexture(colorCanvas);
|
|
const normalTexture = new THREE.CanvasTexture(normalCanvas);
|
|
const roughnessTexture = new THREE.CanvasTexture(roughnessCanvas);
|
|
[colorTexture, normalTexture, roughnessTexture].forEach((texture) => {
|
|
texture.wrapS = THREE.RepeatWrapping;
|
|
texture.wrapT = THREE.RepeatWrapping;
|
|
texture.repeat.set(3.6, 2.2);
|
|
texture.anisotropy = maxTextureAnisotropy;
|
|
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
|
texture.magFilter = THREE.LinearFilter;
|
|
texture.generateMipmaps = true;
|
|
});
|
|
colorTexture.colorSpace = THREE.SRGBColorSpace;
|
|
normalTexture.colorSpace = THREE.NoColorSpace;
|
|
roughnessTexture.colorSpace = THREE.NoColorSpace;
|
|
return { color: colorTexture, normal: normalTexture, roughness: roughnessTexture };
|
|
}
|
|
|
|
function createSpineClothTextures() {
|
|
const size = 1024;
|
|
const colorCanvas = document.createElement('canvas');
|
|
const normalCanvas = document.createElement('canvas');
|
|
const roughnessCanvas = document.createElement('canvas');
|
|
colorCanvas.width = size;
|
|
colorCanvas.height = size;
|
|
normalCanvas.width = size;
|
|
normalCanvas.height = size;
|
|
roughnessCanvas.width = size;
|
|
roughnessCanvas.height = size;
|
|
const colorContext = colorCanvas.getContext('2d');
|
|
const normalContext = normalCanvas.getContext('2d');
|
|
const roughnessContext = roughnessCanvas.getContext('2d');
|
|
const colorImage = colorContext.createImageData(size, size);
|
|
const normalImage = normalContext.createImageData(size, size);
|
|
const roughnessImage = roughnessContext.createImageData(size, size);
|
|
const threadAt = (x, y) => {
|
|
const nx = x / size;
|
|
const ny = y / size;
|
|
const warpPhase = nx * 112 + Math.sin(ny * 31.4159265359) * 0.025;
|
|
const weftPhase = ny * 76 + Math.sin(nx * 25.1327412287) * 0.02;
|
|
const warp = Math.pow(1 - Math.abs((warpPhase - Math.floor(warpPhase)) - 0.5) * 2, 2.2);
|
|
const weft = Math.pow(1 - Math.abs((weftPhase - Math.floor(weftPhase)) - 0.5) * 2, 2.0);
|
|
const fiber = Math.sin((nx * 430 + ny * 73) * 6.28318530718) * Math.sin((ny * 390 - nx * 41) * 6.28318530718);
|
|
const nap = Math.sin((nx * 19 + ny * 7) * 6.28318530718);
|
|
return warp * 0.46 + weft * 0.38 + fiber * 0.045 + nap * 0.055;
|
|
};
|
|
|
|
for (let y = 0; y < size; y += 1) {
|
|
for (let x = 0; x < size; x += 1) {
|
|
const index = (y * size + x) * 4;
|
|
const height = threadAt(x, y);
|
|
const wornFiber = 0.86 + 0.1 * Math.sin((x * 0.019 + y * 0.037)) + 0.04 * Math.sin((x * 0.083 - y * 0.011));
|
|
const threadGlow = THREE.MathUtils.clamp(0.58 + height * 0.46, 0, 1);
|
|
colorImage.data[index] = Math.round(128 * threadGlow * wornFiber);
|
|
colorImage.data[index + 1] = Math.round(22 * threadGlow * wornFiber);
|
|
colorImage.data[index + 2] = Math.round(18 * (0.86 + height * 0.12));
|
|
colorImage.data[index + 3] = 255;
|
|
|
|
const hLeft = threadAt((x - 1 + size) % size, y);
|
|
const hRight = threadAt((x + 1) % size, y);
|
|
const hDown = threadAt(x, (y - 1 + size) % size);
|
|
const hUp = threadAt(x, (y + 1) % size);
|
|
const normal = new THREE.Vector3((hLeft - hRight) * 5.4, (hDown - hUp) * 5.4, 1).normalize();
|
|
normalImage.data[index] = Math.round((normal.x * 0.5 + 0.5) * 255);
|
|
normalImage.data[index + 1] = Math.round((normal.y * 0.5 + 0.5) * 255);
|
|
normalImage.data[index + 2] = Math.round((normal.z * 0.5 + 0.5) * 255);
|
|
normalImage.data[index + 3] = 255;
|
|
|
|
const fiberContrast = Math.abs(hLeft - hRight) + Math.abs(hDown - hUp);
|
|
const roughness = THREE.MathUtils.clamp(0.84 + height * 0.07 + fiberContrast * 1.25, 0.62, 0.98);
|
|
const roughnessByte = Math.round(roughness * 255);
|
|
roughnessImage.data[index] = roughnessByte;
|
|
roughnessImage.data[index + 1] = roughnessByte;
|
|
roughnessImage.data[index + 2] = roughnessByte;
|
|
roughnessImage.data[index + 3] = 255;
|
|
}
|
|
}
|
|
|
|
colorContext.putImageData(colorImage, 0, 0);
|
|
normalContext.putImageData(normalImage, 0, 0);
|
|
roughnessContext.putImageData(roughnessImage, 0, 0);
|
|
const colorTexture = new THREE.CanvasTexture(colorCanvas);
|
|
const normalTexture = new THREE.CanvasTexture(normalCanvas);
|
|
const roughnessTexture = new THREE.CanvasTexture(roughnessCanvas);
|
|
[colorTexture, normalTexture, roughnessTexture].forEach((texture) => {
|
|
texture.wrapS = THREE.RepeatWrapping;
|
|
texture.wrapT = THREE.RepeatWrapping;
|
|
texture.repeat.set(2.1, 4.4);
|
|
texture.anisotropy = maxTextureAnisotropy;
|
|
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
|
texture.magFilter = THREE.LinearFilter;
|
|
texture.generateMipmaps = true;
|
|
});
|
|
colorTexture.colorSpace = THREE.SRGBColorSpace;
|
|
normalTexture.colorSpace = THREE.NoColorSpace;
|
|
roughnessTexture.colorSpace = THREE.NoColorSpace;
|
|
return { color: colorTexture, normal: normalTexture, roughness: roughnessTexture };
|
|
}
|
|
|
|
function createHeadbandTextures() {
|
|
const width = 1024;
|
|
const height = 256;
|
|
const colorCanvas = document.createElement('canvas');
|
|
const normalCanvas = document.createElement('canvas');
|
|
const roughnessCanvas = document.createElement('canvas');
|
|
colorCanvas.width = width;
|
|
colorCanvas.height = height;
|
|
normalCanvas.width = width;
|
|
normalCanvas.height = height;
|
|
roughnessCanvas.width = width;
|
|
roughnessCanvas.height = height;
|
|
const colorContext = colorCanvas.getContext('2d');
|
|
const normalContext = normalCanvas.getContext('2d');
|
|
const roughnessContext = roughnessCanvas.getContext('2d');
|
|
const colorImage = colorContext.createImageData(width, height);
|
|
const normalImage = normalContext.createImageData(width, height);
|
|
const roughnessImage = roughnessContext.createImageData(width, height);
|
|
const threadAt = (x, y) => {
|
|
const u = x / width;
|
|
const v = y / height;
|
|
const wrap = u * 44 + v * 7.5;
|
|
const phase = wrap - Math.floor(wrap);
|
|
const rib = Math.pow(1 - Math.abs(phase - 0.5) * 2, 0.55);
|
|
const warp = Math.pow(1 - Math.abs(((u * 190 + v * 9) % 1) - 0.5) * 2, 1.1);
|
|
const weft = Math.pow(1 - Math.abs(((v * 38 + u * 4.5) % 1) - 0.5) * 2, 1.25);
|
|
return rib * 0.72 + warp * 0.16 + weft * 0.12;
|
|
};
|
|
|
|
for (let y = 0; y < height; y += 1) {
|
|
for (let x = 0; x < width; x += 1) {
|
|
const index = (y * width + x) * 4;
|
|
const u = x / width;
|
|
const v = y / height;
|
|
const wrap = u * 44 + v * 7.5;
|
|
const alternate = Math.floor(wrap) % 2;
|
|
const heightValue = threadAt(x, y);
|
|
const cotton = Math.sin((u * 410 + v * 79) * 6.28318530718) * 0.025;
|
|
const shade = THREE.MathUtils.clamp(0.76 + heightValue * 0.18 + cotton, 0.58, 1.0);
|
|
const red = [166, 30, 24];
|
|
const ivory = [218, 190, 136];
|
|
const linen = [152, 116, 82];
|
|
const base = alternate === 0 ? red : ivory;
|
|
const blend = THREE.MathUtils.clamp(heightValue * 1.08, 0, 1);
|
|
colorImage.data[index] = Math.round(THREE.MathUtils.lerp(linen[0], base[0], blend) * shade);
|
|
colorImage.data[index + 1] = Math.round(THREE.MathUtils.lerp(linen[1], base[1], blend) * shade);
|
|
colorImage.data[index + 2] = Math.round(THREE.MathUtils.lerp(linen[2], base[2], blend) * shade);
|
|
colorImage.data[index + 3] = 255;
|
|
|
|
const hLeft = threadAt((x - 1 + width) % width, y);
|
|
const hRight = threadAt((x + 1) % width, y);
|
|
const hDown = threadAt(x, (y - 1 + height) % height);
|
|
const hUp = threadAt(x, (y + 1) % height);
|
|
const normal = new THREE.Vector3((hLeft - hRight) * 3.8, (hDown - hUp) * 3.8, 1).normalize();
|
|
normalImage.data[index] = Math.round((normal.x * 0.5 + 0.5) * 255);
|
|
normalImage.data[index + 1] = Math.round((normal.y * 0.5 + 0.5) * 255);
|
|
normalImage.data[index + 2] = Math.round((normal.z * 0.5 + 0.5) * 255);
|
|
normalImage.data[index + 3] = 255;
|
|
|
|
const roughness = THREE.MathUtils.clamp(0.74 + heightValue * 0.16 + Math.abs(hLeft - hRight) * 0.8, 0.58, 0.96);
|
|
const roughnessByte = Math.round(roughness * 255);
|
|
roughnessImage.data[index] = roughnessByte;
|
|
roughnessImage.data[index + 1] = roughnessByte;
|
|
roughnessImage.data[index + 2] = roughnessByte;
|
|
roughnessImage.data[index + 3] = 255;
|
|
}
|
|
}
|
|
|
|
colorContext.putImageData(colorImage, 0, 0);
|
|
normalContext.putImageData(normalImage, 0, 0);
|
|
roughnessContext.putImageData(roughnessImage, 0, 0);
|
|
const colorTexture = new THREE.CanvasTexture(colorCanvas);
|
|
const normalTexture = new THREE.CanvasTexture(normalCanvas);
|
|
const roughnessTexture = new THREE.CanvasTexture(roughnessCanvas);
|
|
[colorTexture, normalTexture, roughnessTexture].forEach((texture) => {
|
|
texture.wrapS = THREE.RepeatWrapping;
|
|
texture.wrapT = THREE.RepeatWrapping;
|
|
texture.repeat.set(1.0, 1.0);
|
|
texture.anisotropy = maxTextureAnisotropy;
|
|
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
|
texture.magFilter = THREE.LinearFilter;
|
|
texture.generateMipmaps = true;
|
|
});
|
|
colorTexture.colorSpace = THREE.SRGBColorSpace;
|
|
normalTexture.colorSpace = THREE.NoColorSpace;
|
|
roughnessTexture.colorSpace = THREE.NoColorSpace;
|
|
return { color: colorTexture, normal: normalTexture, roughness: roughnessTexture };
|
|
}
|
|
|
|
function createHardcoverPaperTextures() {
|
|
const size = 1024;
|
|
const colorCanvas = document.createElement('canvas');
|
|
const edgeCanvas = document.createElement('canvas');
|
|
const normalCanvas = document.createElement('canvas');
|
|
const roughnessCanvas = document.createElement('canvas');
|
|
[colorCanvas, edgeCanvas, normalCanvas, roughnessCanvas].forEach((canvas) => {
|
|
canvas.width = size;
|
|
canvas.height = size;
|
|
});
|
|
const colorContext = colorCanvas.getContext('2d');
|
|
const edgeContext = edgeCanvas.getContext('2d');
|
|
const normalContext = normalCanvas.getContext('2d');
|
|
const roughnessContext = roughnessCanvas.getContext('2d');
|
|
const colorImage = colorContext.createImageData(size, size);
|
|
const edgeImage = edgeContext.createImageData(size, size);
|
|
const normalImage = normalContext.createImageData(size, size);
|
|
const roughnessImage = roughnessContext.createImageData(size, size);
|
|
const fiberAt = (x, y) => {
|
|
const nx = x / size;
|
|
const ny = y / size;
|
|
const pulpA = Math.sin((nx * 173 + ny * 67) * 6.28318530718);
|
|
const pulpB = Math.sin((nx * 89 - ny * 131) * 6.28318530718);
|
|
const cloudA = Math.sin((nx * 19 + ny * 11) * 6.28318530718);
|
|
const cloudB = Math.sin((nx * 31 - ny * 27) * 6.28318530718);
|
|
const fleck = Math.max(0, 0.5 - Math.abs(pulpA * pulpB));
|
|
return cloudA * cloudB * 0.014 - fleck * 0.018;
|
|
};
|
|
|
|
for (let y = 0; y < size; y += 1) {
|
|
for (let x = 0; x < size; x += 1) {
|
|
const index = (y * size + x) * 4;
|
|
const fiber = fiberAt(x, y);
|
|
const warmth = 0.985 + 0.008 * Math.sin(x * 0.017 + y * 0.003) + 0.006 * Math.sin(y * 0.041);
|
|
const shade = THREE.MathUtils.clamp(0.985 + fiber, 0.92, 1.0);
|
|
colorImage.data[index] = Math.round(246 * shade * warmth);
|
|
colorImage.data[index + 1] = Math.round(239 * shade * warmth);
|
|
colorImage.data[index + 2] = Math.round(216 * shade);
|
|
colorImage.data[index + 3] = 255;
|
|
|
|
const linePhase = (y + Math.sin(x * 0.021) * 4) % 34;
|
|
const line = linePhase < 1.2 ? 0.72 : linePhase < 2.1 ? 0.82 : 1;
|
|
edgeImage.data[index] = Math.round(240 * shade * line);
|
|
edgeImage.data[index + 1] = Math.round(230 * shade * line);
|
|
edgeImage.data[index + 2] = Math.round(194 * shade * line);
|
|
edgeImage.data[index + 3] = 255;
|
|
|
|
const hLeft = fiberAt((x - 1 + size) % size, y);
|
|
const hRight = fiberAt((x + 1) % size, y);
|
|
const hDown = fiberAt(x, (y - 1 + size) % size);
|
|
const hUp = fiberAt(x, (y + 1) % size);
|
|
const normal = new THREE.Vector3((hLeft - hRight) * 1.45, (hDown - hUp) * 1.45, 1).normalize();
|
|
normalImage.data[index] = Math.round((normal.x * 0.5 + 0.5) * 255);
|
|
normalImage.data[index + 1] = Math.round((normal.y * 0.5 + 0.5) * 255);
|
|
normalImage.data[index + 2] = Math.round((normal.z * 0.5 + 0.5) * 255);
|
|
normalImage.data[index + 3] = 255;
|
|
|
|
const roughness = THREE.MathUtils.clamp(0.86 + Math.abs(fiber) * 0.5 + Math.abs(hLeft - hRight + hDown - hUp) * 1.2, 0.72, 0.98);
|
|
const roughnessByte = Math.round(roughness * 255);
|
|
roughnessImage.data[index] = roughnessByte;
|
|
roughnessImage.data[index + 1] = roughnessByte;
|
|
roughnessImage.data[index + 2] = roughnessByte;
|
|
roughnessImage.data[index + 3] = 255;
|
|
}
|
|
}
|
|
|
|
colorContext.putImageData(colorImage, 0, 0);
|
|
edgeContext.putImageData(edgeImage, 0, 0);
|
|
normalContext.putImageData(normalImage, 0, 0);
|
|
roughnessContext.putImageData(roughnessImage, 0, 0);
|
|
const colorTexture = new THREE.CanvasTexture(colorCanvas);
|
|
const edgeTexture = new THREE.CanvasTexture(edgeCanvas);
|
|
const normalTexture = new THREE.CanvasTexture(normalCanvas);
|
|
const roughnessTexture = new THREE.CanvasTexture(roughnessCanvas);
|
|
[colorTexture, edgeTexture, normalTexture, roughnessTexture].forEach((texture) => {
|
|
texture.wrapS = THREE.RepeatWrapping;
|
|
texture.wrapT = THREE.RepeatWrapping;
|
|
texture.repeat.set(2.6, 3.4);
|
|
texture.anisotropy = maxTextureAnisotropy;
|
|
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
|
texture.magFilter = THREE.LinearFilter;
|
|
texture.generateMipmaps = true;
|
|
});
|
|
colorTexture.colorSpace = THREE.SRGBColorSpace;
|
|
edgeTexture.colorSpace = THREE.SRGBColorSpace;
|
|
normalTexture.colorSpace = THREE.NoColorSpace;
|
|
roughnessTexture.colorSpace = THREE.NoColorSpace;
|
|
return { color: colorTexture, edge: edgeTexture, normal: normalTexture, roughness: roughnessTexture };
|
|
}
|
|
|
|
function createRoomReflectionTexture() {
|
|
const canvas = document.createElement('canvas');
|
|
generatedTextureCanvases.roomReflection = canvas;
|
|
canvas.width = 2048;
|
|
canvas.height = 1024;
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
const wall = ctx.createLinearGradient(0, 0, 0, canvas.height);
|
|
wall.addColorStop(0, '#050302');
|
|
wall.addColorStop(0.36, '#140906');
|
|
wall.addColorStop(0.72, '#2b150b');
|
|
wall.addColorStop(1, '#060302');
|
|
ctx.fillStyle = wall;
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
ctx.globalAlpha = 0.28;
|
|
for (let i = 0; i < 7; i += 1) {
|
|
const x = 170 + i * 285;
|
|
const glow = ctx.createRadialGradient(x, 520, 0, x, 520, 300);
|
|
glow.addColorStop(0, 'rgba(255, 157, 64, 0.55)');
|
|
glow.addColorStop(0.2, 'rgba(144, 68, 27, 0.28)');
|
|
glow.addColorStop(1, 'rgba(0, 0, 0, 0)');
|
|
ctx.fillStyle = glow;
|
|
ctx.fillRect(x - 330, 190, 660, 660);
|
|
}
|
|
|
|
ctx.globalAlpha = 0.16;
|
|
ctx.fillStyle = '#c99655';
|
|
for (let i = 0; i < 10; i += 1) {
|
|
ctx.fillRect(120 + i * 190, 230, 52, 390);
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
|
|
const texture = new THREE.CanvasTexture(canvas);
|
|
texture.colorSpace = THREE.SRGBColorSpace;
|
|
texture.mapping = THREE.EquirectangularReflectionMapping;
|
|
texture.needsUpdate = true;
|
|
return texture;
|
|
}
|
|
|
|
function loadAiRoomReflection() {
|
|
return new Promise((resolve) => {
|
|
new THREE.TextureLoader().load('/assets/webgl/room_reflection_candlelit_study_equirect_4k.png', (texture) => {
|
|
texture.colorSpace = THREE.SRGBColorSpace;
|
|
texture.mapping = THREE.EquirectangularReflectionMapping;
|
|
texture.anisotropy = renderer.capabilities.getMaxAnisotropy();
|
|
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
|
texture.magFilter = THREE.LinearFilter;
|
|
texture.generateMipmaps = true;
|
|
texture.needsUpdate = true;
|
|
tableRoomReflectionTexture = texture;
|
|
if (tableShader) {
|
|
tableShader.uniforms.roomReflectionMap.value = texture;
|
|
}
|
|
markStaticSceneBuffersDirty();
|
|
|
|
const image = texture.image;
|
|
if (image) {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = image.naturalWidth || image.width;
|
|
canvas.height = image.naturalHeight || image.height;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
generatedTextureCanvases.aiRoomReflection = canvas;
|
|
tintAmbientFromCanvas(canvas);
|
|
markStaticSceneBuffersDirty();
|
|
}
|
|
resolve(texture);
|
|
}, undefined, () => {
|
|
tintAmbientFromCanvas(generatedTextureCanvases.roomReflection);
|
|
markStaticSceneBuffersDirty();
|
|
resolve(tableRoomReflectionTexture);
|
|
});
|
|
});
|
|
}
|
|
|
|
function primeSceneForLoader() {
|
|
markLoaderTiming('primeSceneForLoader:start');
|
|
updateCameraRig(0);
|
|
updateCandleShadowUniforms();
|
|
markLoaderTiming('bookShadowMaps:start');
|
|
updateBookShadowMaps();
|
|
markLoaderTiming('bookShadowMaps:end');
|
|
markLoaderTiming('tableReflection:start');
|
|
updateTableReflection();
|
|
markLoaderTiming('tableReflection:end');
|
|
markLoaderTiming('shaderCompile:start');
|
|
renderer.compile(scene, camera);
|
|
markLoaderTiming('shaderCompile:end');
|
|
staticSceneBuffersDirty = false;
|
|
markLoaderTiming('primeSceneForLoader:end');
|
|
}
|
|
|
|
function tintAmbientFromCanvas(canvas) {
|
|
if (!canvas || !candleBounceLight) return;
|
|
const ctx = canvas.getContext('2d');
|
|
const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
let r = 0;
|
|
let g = 0;
|
|
let b = 0;
|
|
let count = 0;
|
|
for (let i = 0; i < data.length; i += 256) {
|
|
r += data[i];
|
|
g += data[i + 1];
|
|
b += data[i + 2];
|
|
count += 1;
|
|
}
|
|
const color = new THREE.Color(r / count / 255, g / count / 255, b / count / 255);
|
|
color.offsetHSL(0, 0.08, 0.04);
|
|
candleBounceLight.color.copy(color);
|
|
candleBounceLight.intensity = 0.28;
|
|
}
|
|
|
|
function resize() {
|
|
const width = Math.max(1, window.innerWidth);
|
|
const height = Math.max(1, window.innerHeight);
|
|
const sizeChanged = width !== lastResizeWidth || height !== lastResizeHeight;
|
|
lastResizeWidth = width;
|
|
lastResizeHeight = height;
|
|
if (sizeChanged) markStaticSceneBuffersDirty();
|
|
renderer.setSize(width, height, false);
|
|
if (composer) composer.setSize(width, height);
|
|
if (sceneAoPass) sceneAoPass.setSize(width, height);
|
|
camera.aspect = width / height;
|
|
camera.updateProjectionMatrix();
|
|
const desiredReflectionScale = reflectionPixelRatio * 1.5;
|
|
const reflectionScale = Math.max(isAppIntegrationMode ? 0.35 : 1, Math.min(
|
|
desiredReflectionScale,
|
|
4096 / width,
|
|
2304 / height
|
|
));
|
|
const reflectionWidth = Math.min(tableReflectionBaseWidth, Math.floor(width * reflectionScale));
|
|
const reflectionHeight = Math.min(tableReflectionBaseHeight, Math.floor(height * reflectionScale));
|
|
reflectionTargetSize.set(reflectionWidth, reflectionHeight);
|
|
tableReflectionTarget.setSize(
|
|
reflectionTargetSize.x,
|
|
reflectionTargetSize.y
|
|
);
|
|
}
|
|
|
|
function installCameraControls() {
|
|
canvas.addEventListener('contextmenu', (event) => {
|
|
event.preventDefault();
|
|
});
|
|
|
|
canvas.addEventListener('pointerdown', (event) => {
|
|
if (event.button !== 2) return;
|
|
cameraRig.dragging = true;
|
|
cameraRig.navigationActive = true;
|
|
canvas.style.cursor = 'grabbing';
|
|
cameraRig.pointerX = event.clientX;
|
|
cameraRig.pointerY = event.clientY;
|
|
canvas.setPointerCapture(event.pointerId);
|
|
});
|
|
|
|
canvas.addEventListener('pointermove', (event) => {
|
|
if (!cameraRig.dragging) return;
|
|
const dx = event.clientX - cameraRig.pointerX;
|
|
const dy = event.clientY - cameraRig.pointerY;
|
|
cameraRig.pointerX = event.clientX;
|
|
cameraRig.pointerY = event.clientY;
|
|
cameraRig.yaw -= dx * 0.006;
|
|
cameraRig.pitch = THREE.MathUtils.clamp(
|
|
cameraRig.pitch + dy * 0.004,
|
|
cameraRig.minPitch,
|
|
cameraRig.maxPitch
|
|
);
|
|
updateCameraRig(0);
|
|
});
|
|
|
|
canvas.addEventListener('pointerup', (event) => {
|
|
if (event.button !== 2) return;
|
|
cameraRig.dragging = false;
|
|
cameraRig.navigationActive = false;
|
|
cameraRig.keys.clear();
|
|
canvas.style.cursor = 'grab';
|
|
canvas.releasePointerCapture(event.pointerId);
|
|
});
|
|
|
|
canvas.addEventListener('pointercancel', () => {
|
|
cameraRig.dragging = false;
|
|
cameraRig.navigationActive = false;
|
|
cameraRig.keys.clear();
|
|
canvas.style.cursor = 'grab';
|
|
});
|
|
|
|
canvas.addEventListener('wheel', (event) => {
|
|
if (!cameraRig.navigationActive) return;
|
|
event.preventDefault();
|
|
const zoom = Math.exp(event.deltaY * 0.001);
|
|
cameraRig.radius = THREE.MathUtils.clamp(
|
|
cameraRig.radius * zoom,
|
|
cameraRig.minRadius,
|
|
cameraRig.maxRadius
|
|
);
|
|
updateCameraRig(0);
|
|
}, { passive: false });
|
|
|
|
window.addEventListener('keydown', (event) => {
|
|
if (!cameraRig.navigationActive) return;
|
|
if (['KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(event.code)) {
|
|
cameraRig.keys.add(event.code);
|
|
event.preventDefault();
|
|
}
|
|
});
|
|
|
|
window.addEventListener('keyup', (event) => {
|
|
cameraRig.keys.delete(event.code);
|
|
});
|
|
}
|
|
|
|
function updateCameraRig(deltaSeconds) {
|
|
if (deltaSeconds > 0 && cameraRig.keys.size) {
|
|
const forward = new THREE.Vector3();
|
|
camera.getWorldDirection(forward);
|
|
forward.y = 0;
|
|
forward.normalize();
|
|
const right = new THREE.Vector3().crossVectors(forward, camera.up).normalize();
|
|
const move = new THREE.Vector3();
|
|
if (cameraRig.keys.has('KeyW')) move.add(forward);
|
|
if (cameraRig.keys.has('KeyS')) move.sub(forward);
|
|
if (cameraRig.keys.has('KeyD')) move.add(right);
|
|
if (cameraRig.keys.has('KeyA')) move.sub(right);
|
|
if (move.lengthSq() > 0) {
|
|
move.normalize().multiplyScalar(deltaSeconds * cameraRig.radius * 0.72);
|
|
cameraRig.target.add(move);
|
|
cameraRig.target.x = THREE.MathUtils.clamp(cameraRig.target.x, -2.6, 2.6);
|
|
cameraRig.target.z = THREE.MathUtils.clamp(cameraRig.target.z, -1.9, 1.9);
|
|
}
|
|
}
|
|
|
|
const horizontalRadius = Math.sin(cameraRig.pitch) * cameraRig.radius;
|
|
camera.position.set(
|
|
cameraRig.target.x + Math.sin(cameraRig.yaw) * horizontalRadius,
|
|
cameraRig.target.y + Math.cos(cameraRig.pitch) * cameraRig.radius,
|
|
cameraRig.target.z + Math.cos(cameraRig.yaw) * horizontalRadius
|
|
);
|
|
camera.lookAt(cameraRig.target);
|
|
const signature = [
|
|
camera.position.x,
|
|
camera.position.y,
|
|
camera.position.z,
|
|
cameraRig.target.x,
|
|
cameraRig.target.y,
|
|
cameraRig.target.z,
|
|
camera.aspect
|
|
].map((value) => value.toFixed(4)).join('|');
|
|
if (signature !== lastStaticCameraSignature) {
|
|
lastStaticCameraSignature = signature;
|
|
markStaticSceneBuffersDirty();
|
|
}
|
|
}
|
|
|
|
function updateCandleShadowUniforms() {
|
|
if (!tableShader) return;
|
|
candleShadowSources.forEach((candle, index) => {
|
|
if (index >= 3) return;
|
|
candle.getWorldPosition(candleWorldPosition);
|
|
candle.userData.flame.getWorldPosition(flameWorldPosition);
|
|
tableShader.uniforms.candleBodyPositions.value[index].set(
|
|
candleWorldPosition.x,
|
|
candleWorldPosition.y - 0.05,
|
|
candleWorldPosition.z
|
|
);
|
|
tableShader.uniforms.candleFlamePositions.value[index].copy(flameWorldPosition);
|
|
tableShader.uniforms.candleBodyData.value[index].set(
|
|
candle.userData.bodyRadius,
|
|
candle.userData.bodyHeight
|
|
);
|
|
});
|
|
}
|
|
|
|
function updateBookShadowMaps() {
|
|
if (!tableShader || candleShadowSources.length < 3) return;
|
|
|
|
const previousRenderTarget = renderer.getRenderTarget();
|
|
const previousXrEnabled = renderer.xr.enabled;
|
|
const previousShadowAutoUpdate = renderer.shadowMap.autoUpdate;
|
|
const previousToneMappingExposure = renderer.toneMappingExposure;
|
|
const previousOverrideMaterial = scene.overrideMaterial;
|
|
const previousClearColor = new THREE.Color();
|
|
renderer.getClearColor(previousClearColor);
|
|
const previousClearAlpha = renderer.getClearAlpha();
|
|
|
|
const hiddenObjects = [tableMesh, ...candleShadowSources].filter(Boolean);
|
|
hiddenObjects.forEach((object) => {
|
|
object.userData.wasVisibleForBookShadow = object.visible;
|
|
object.visible = false;
|
|
});
|
|
|
|
renderer.xr.enabled = false;
|
|
renderer.shadowMap.autoUpdate = false;
|
|
renderer.toneMappingExposure = 1;
|
|
renderer.setClearColor(0xffffff, 1);
|
|
scene.overrideMaterial = bookShadowDepthMaterial;
|
|
|
|
candleShadowSources.forEach((candle, index) => {
|
|
candle.userData.flame.getWorldPosition(flameWorldPosition);
|
|
const shadowCamera = bookShadowCameras[index];
|
|
shadowCamera.position.copy(flameWorldPosition);
|
|
shadowCamera.lookAt(0, 0.09, 0);
|
|
shadowCamera.updateProjectionMatrix();
|
|
shadowCamera.updateMatrixWorld();
|
|
shadowCamera.matrixWorldInverse.copy(shadowCamera.matrixWorld).invert();
|
|
|
|
bookShadowMatrices[index]
|
|
.copy(bookShadowBiasMatrix)
|
|
.multiply(shadowCamera.projectionMatrix)
|
|
.multiply(shadowCamera.matrixWorldInverse);
|
|
|
|
renderer.setRenderTarget(bookShadowTargets[index]);
|
|
renderer.clear();
|
|
renderer.render(scene, shadowCamera);
|
|
});
|
|
|
|
scene.overrideMaterial = previousOverrideMaterial;
|
|
hiddenObjects.forEach((object) => {
|
|
object.visible = object.userData.wasVisibleForBookShadow;
|
|
delete object.userData.wasVisibleForBookShadow;
|
|
});
|
|
renderer.setClearColor(previousClearColor, previousClearAlpha);
|
|
renderer.toneMappingExposure = previousToneMappingExposure;
|
|
renderer.shadowMap.autoUpdate = previousShadowAutoUpdate;
|
|
renderer.xr.enabled = previousXrEnabled;
|
|
renderer.setRenderTarget(previousRenderTarget);
|
|
}
|
|
|
|
function updateTableReflection() {
|
|
if (!tableMesh || !tableShader) return;
|
|
|
|
tableReflectionCamera.copy(camera);
|
|
tableReflectionCamera.position.set(
|
|
camera.position.x,
|
|
tableTopY - (camera.position.y - tableTopY),
|
|
camera.position.z
|
|
);
|
|
|
|
reflectionTarget.copy(cameraRig.target);
|
|
reflectionTarget.y = tableTopY - (reflectionTarget.y - tableTopY);
|
|
reflectionUp.setFromMatrixColumn(camera.matrixWorld, 1);
|
|
reflectionUp.y *= -1;
|
|
tableReflectionCamera.up.copy(reflectionUp);
|
|
tableReflectionCamera.lookAt(reflectionTarget);
|
|
tableReflectionCamera.updateProjectionMatrix();
|
|
tableReflectionCamera.updateMatrixWorld();
|
|
tableReflectionCamera.matrixWorldInverse.copy(tableReflectionCamera.matrixWorld).invert();
|
|
tableReflectionMatrix
|
|
.copy(tableReflectionBiasMatrix)
|
|
.multiply(tableReflectionCamera.projectionMatrix)
|
|
.multiply(tableReflectionCamera.matrixWorldInverse);
|
|
|
|
const previousRenderTarget = renderer.getRenderTarget();
|
|
const previousXrEnabled = renderer.xr.enabled;
|
|
const previousShadowAutoUpdate = renderer.shadowMap.autoUpdate;
|
|
const previousToneMappingExposure = renderer.toneMappingExposure;
|
|
const pageTextureState = suppressPageContentMaps();
|
|
|
|
tableMesh.userData.wasVisibleForTableReflection = tableMesh.visible;
|
|
tableMesh.visible = false;
|
|
renderer.xr.enabled = false;
|
|
renderer.shadowMap.autoUpdate = false;
|
|
renderer.toneMappingExposure = 0.92;
|
|
renderer.setRenderTarget(tableReflectionTarget);
|
|
renderer.clear();
|
|
renderer.render(scene, tableReflectionCamera);
|
|
renderer.setRenderTarget(previousRenderTarget);
|
|
renderer.toneMappingExposure = previousToneMappingExposure;
|
|
renderer.shadowMap.autoUpdate = previousShadowAutoUpdate;
|
|
renderer.xr.enabled = previousXrEnabled;
|
|
restorePageContentMaps(pageTextureState);
|
|
tableMesh.visible = tableMesh.userData.wasVisibleForTableReflection;
|
|
delete tableMesh.userData.wasVisibleForTableReflection;
|
|
}
|
|
|
|
function suppressPageContentMaps() {
|
|
if (!isAppIntegrationMode) return null;
|
|
return [materials.leftPage, materials.rightPage].map((material) => {
|
|
const previousMap = material.map;
|
|
material.map = null;
|
|
material.needsUpdate = true;
|
|
return { material, previousMap };
|
|
});
|
|
}
|
|
|
|
function restorePageContentMaps(state) {
|
|
if (!state) return;
|
|
state.forEach(({ material, previousMap }) => {
|
|
material.map = previousMap;
|
|
material.needsUpdate = true;
|
|
});
|
|
}
|
|
|
|
function renderMirrorDebugView() {
|
|
const hiddenObjects = [];
|
|
scene.traverse((object) => {
|
|
if (object === tableMesh || !object.visible) return;
|
|
if (!object.isMesh && !object.isLine && !object.isPoints && !object.isSprite) return;
|
|
object.userData.wasVisibleForMirrorDebug = true;
|
|
object.visible = false;
|
|
hiddenObjects.push(object);
|
|
});
|
|
renderer.render(scene, camera);
|
|
hiddenObjects.forEach((object) => {
|
|
object.visible = object.userData.wasVisibleForMirrorDebug;
|
|
delete object.userData.wasVisibleForMirrorDebug;
|
|
});
|
|
}
|
|
|
|
function animate(now = performance.now()) {
|
|
const elapsedSinceLastFrame = lastRenderFrameAt ? now - lastRenderFrameAt : targetFrameDurationMs;
|
|
if (lastRenderFrameAt && elapsedSinceLastFrame < targetFrameDurationMs) {
|
|
requestAnimationFrame(animate);
|
|
return;
|
|
}
|
|
const frameElapsedMs = lastRenderFrameAt ? elapsedSinceLastFrame : targetFrameDurationMs;
|
|
lastRenderFrameAt = now;
|
|
requestAnimationFrame(animate);
|
|
const delta = Math.min(0.1, frameElapsedMs / 1000);
|
|
clock.getDelta();
|
|
const t = clock.elapsedTime;
|
|
updateCameraRig(delta);
|
|
scene.traverse((object) => {
|
|
if (!object.userData?.light) return;
|
|
const swayX = Math.sin(t * 5.7 + object.userData.seed) * 0.012;
|
|
const swayZ = Math.cos(t * 4.9 + object.userData.seed * 0.7) * 0.01;
|
|
const pulse = 0.9 + Math.sin(t * 7.3 + object.userData.seed) * 0.09 + Math.sin(t * 13.1) * 0.045;
|
|
object.userData.light.intensity = object.userData.baseIntensity * pulse * (object.position.x < 0 ? 1.08 : 0.92);
|
|
object.userData.flame.scale.y = 1.65 + Math.sin(t * 9.2 + object.userData.seed) * 0.18;
|
|
object.userData.flame.position.x = swayX * 0.75;
|
|
object.userData.flame.position.z = swayZ * 0.75;
|
|
object.userData.flame.traverse((child) => {
|
|
if (child.material?.uniforms?.time) child.material.uniforms.time.value = t + object.userData.seed;
|
|
});
|
|
object.userData.light.position.copy(object.userData.flame.position);
|
|
object.userData.waxGlow.material.opacity = 0.07 + Math.max(0, pulse - 0.9) * 0.08;
|
|
const waxShader = object.userData.waxMaterial.userData.shader;
|
|
if (waxShader) {
|
|
object.getWorldPosition(candleWorldPosition);
|
|
object.userData.flame.getWorldPosition(flameWorldPosition);
|
|
waxShader.uniforms.waxFlameWorldPosition.value.copy(flameWorldPosition);
|
|
waxShader.uniforms.waxBodyWorldPosition.value.set(
|
|
candleWorldPosition.x,
|
|
candleWorldPosition.y - 0.05,
|
|
candleWorldPosition.z
|
|
);
|
|
waxShader.uniforms.waxLightPower.value = THREE.MathUtils.clamp(pulse * object.userData.baseIntensity * 0.42, 0.35, 1.6);
|
|
}
|
|
});
|
|
const hadActiveFlips = activeFlips.length > 0;
|
|
updateActiveFlips(performance.now());
|
|
if (hadActiveFlips) markStaticSceneBuffersDirty();
|
|
updatePageRevealAnimations(now);
|
|
updateCandleShadowUniforms();
|
|
renderedFrameCount += 1;
|
|
const shadowStartedAt = performance.now();
|
|
updateBookShadowMaps();
|
|
lastFrameTiming.shadows = performance.now() - shadowStartedAt;
|
|
const reflectionStartedAt = performance.now();
|
|
const refreshStaticSceneBuffers = staticSceneBuffersDirty || activeFlips.length > 0;
|
|
updateTableReflection();
|
|
lastFrameTiming.reflection = performance.now() - reflectionStartedAt;
|
|
const renderStartedAt = performance.now();
|
|
if (tableDebugMode === tableDebugModes.mirror) {
|
|
renderer.setRenderTarget(null);
|
|
renderer.clear();
|
|
renderMirrorDebugView();
|
|
} else if (composer) {
|
|
composer.render();
|
|
} else {
|
|
renderer.render(scene, camera);
|
|
}
|
|
lastFrameTiming.render = performance.now() - renderStartedAt;
|
|
lastFrameTiming.total = lastFrameTiming.shadows + lastFrameTiming.reflection + lastFrameTiming.render;
|
|
if (refreshStaticSceneBuffers && activeFlips.length === 0) {
|
|
staticSceneBuffersDirty = false;
|
|
}
|
|
window.BookLabDebug.renderedFrames += 1;
|
|
window.BookLabDebug.ready = true;
|
|
fpsWindowFrames += 1;
|
|
if (now - fpsWindowStartedAt >= 500) {
|
|
const fps = Math.round((fpsWindowFrames * 1000) / Math.max(1, now - fpsWindowStartedAt));
|
|
ensureFpsDisplay().textContent = `${fps} fps`;
|
|
document.documentElement.dataset.webglFps = String(fps);
|
|
fpsWindowFrames = 0;
|
|
fpsWindowStartedAt = now;
|
|
}
|
|
document.documentElement.dataset.webglRenderedFrames = String(window.BookLabDebug.renderedFrames);
|
|
document.documentElement.dataset.webglFrameTiming = JSON.stringify(lastFrameTiming);
|
|
}
|