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