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'; 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 }; const urlParams = new URLSearchParams(window.location.search); const tableDebugName = urlParams.get('tableDebug') || 'none'; const tableDebugMode = tableDebugModes[tableDebugName] ?? tableDebugModes.none; 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 = true; renderer.shadowMap.type = THREE.VSMShadowMap; const generatedTextureCanvases = {}; const maxTextureAnisotropy = renderer.capabilities.getMaxAnisotropy(); const reflectionPixelRatio = Math.min(window.devicePixelRatio || 1, 2); const reflectionTargetSize = new THREE.Vector2(); let sceneComposerTarget = null; let composer = null; let sceneRenderPass = null; let sceneAoPass = null; let sceneSmaaPass = null; let sceneOutputPass = null; const aoExcludedObjects = []; 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 tableReflectionTarget = new THREE.WebGLRenderTarget(4096, 2304, { colorSpace: THREE.SRGBColorSpace, depthBuffer: true, stencilBuffer: false, samples: renderer.capabilities.isWebGL2 ? 8 : 0 }); tableReflectionTarget.texture.colorSpace = THREE.SRGBColorSpace; tableReflectionTarget.texture.minFilter = THREE.LinearFilter; tableReflectionTarget.texture.magFilter = THREE.LinearFilter; tableReflectionTarget.texture.anisotropy = maxTextureAnisotropy; const tableReflectionCamera = new THREE.PerspectiveCamera(); const tableReflectionMatrix = new THREE.Matrix4(); const tableReflectionBiasMatrix = new THREE.Matrix4().set( 0.5, 0, 0, 0.5, 0, 0.5, 0, 0.5, 0, 0, 0.5, 0.5, 0, 0, 0, 1 ); const reflectionTarget = new THREE.Vector3(); const reflectionUp = new THREE.Vector3(); const candleShadowSources = []; const candleWorldPosition = new THREE.Vector3(); const flameWorldPosition = new THREE.Vector3(); const bookShadowMapSize = 1536; const bookShadowTargets = Array.from({ length: 3 }, () => { const target = new THREE.WebGLRenderTarget(bookShadowMapSize, bookShadowMapSize, { colorSpace: THREE.NoColorSpace, depthBuffer: true, stencilBuffer: false }); target.texture.colorSpace = THREE.NoColorSpace; target.texture.minFilter = THREE.LinearFilter; target.texture.magFilter = THREE.LinearFilter; target.texture.generateMipmaps = false; return target; }); const bookShadowCameras = Array.from({ length: 3 }, () => new THREE.PerspectiveCamera(78, 1, 0.03, 7.2)); const bookShadowMatrices = Array.from({ length: 3 }, () => new THREE.Matrix4()); const bookShadowBiasMatrix = new THREE.Matrix4().set( 0.5, 0, 0, 0.5, 0, 0.5, 0, 0.5, 0, 0, 0.5, 0.5, 0, 0, 0, 1 ); const bookShadowDepthMaterial = new THREE.MeshDepthMaterial({ depthPacking: THREE.RGBADepthPacking }); bookShadowDepthMaterial.blending = THREE.NoBlending; const camera = new THREE.PerspectiveCamera(28, 1, 0.1, 40); const cameraRig = { target: new THREE.Vector3(0, 0.16, -0.04), yaw: 0, pitch: 1.06, radius: 5.54, minPitch: 0.28, maxPitch: 1.34, minRadius: 2.4, maxRadius: 9.0, dragging: false, 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 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 materials = { leather: new THREE.MeshStandardMaterial({ color: 0x25130b, roughness: 0.58, metalness: 0.02, envMapIntensity: 0.18 }), coverEdge: new THREE.MeshStandardMaterial({ color: 0x6f4b25, roughness: 0.52, metalness: 0.04, envMapIntensity: 0.22 }), pageBlock: new THREE.MeshStandardMaterial({ color: 0xe3c98f, roughness: 0.82, metalness: 0, envMapIntensity: 0.08 }), pageEdge: new THREE.MeshStandardMaterial({ color: 0xc69f64, roughness: 0.92, metalness: 0, envMapIntensity: 0.08 }), leftPage: new THREE.MeshStandardMaterial({ color: 0xffffff, map: leftTexture, roughness: 0.74, metalness: 0, emissive: 0x2d1e12, emissiveIntensity: 0.18, side: THREE.DoubleSide }), rightPage: new THREE.MeshStandardMaterial({ color: 0xffffff, map: rightTexture, roughness: 0.74, metalness: 0, emissive: 0x2d1e12, emissiveIntensity: 0.18, side: THREE.DoubleSide }) }; configureBookShadowReceiver(materials.leather, 0.52); configureBookShadowReceiver(materials.coverEdge, 0.42); configureBookShadowReceiver(materials.pageBlock, 0.46); configureBookShadowReceiver(materials.pageEdge, 0.34); configureBookShadowReceiver(materials.leftPage, 0.38); configureBookShadowReceiver(materials.rightPage, 0.38); buildTable(); buildLighting(); buildBook(); loadAiRoomReflection(); window.BookLabDebug = { textures: generatedTextureCanvases, ready: false, renderedFrames: 0, get sceneAoPass() { return sceneAoPass; }, get composer() { return composer; }, exportTexture(name) { return generatedTextureCanvases[name]?.toDataURL('image/png') || null; } }; window.addEventListener('resize', resize); installCameraControls(); resize(); 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) { material.customProgramCacheKey = () => `book-shadow-receiver-${strength.toFixed(2)}`; 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.vertexShader = shader.vertexShader .replace( '#include ', '#include \nvarying vec3 vBookReceiverWorldPosition;' ) .replace( '#include ', 'vBookReceiverWorldPosition = (modelMatrix * vec4(transformed, 1.0)).xyz;\n#include ' ); shader.fragmentShader = shader.fragmentShader .replace( '#include ', `#include uniform sampler2D bookShadowMaps[3]; uniform mat4 bookShadowMatrices[3]; uniform vec2 bookShadowMapTexelSize; uniform float bookShadowReceiverStrength; varying vec3 vBookReceiverWorldPosition; 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); }` ) .replace( '#include ', `float bookReceiverShadow = bookReceiverShadowField(vBookReceiverWorldPosition) * bookShadowReceiverStrength; outgoingLight *= mix(vec3(1.0), vec3(0.38, 0.29, 0.2), bookReceiverShadow); #include ` ); }; } 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.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.push(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.push(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 = true; light.shadow.mapSize.set(2048, 2048); light.shadow.bias = -0.00004; light.shadow.normalBias = 0.018; light.shadow.radius = 7; light.shadow.blurSamples = 16; light.shadow.camera.near = 0.04; light.shadow.camera.far = 5.0; candle.add(light); candle.userData = { light, flame, wax, waxMaterial, waxGlow, bodyRadius: 0.12, bodyHeight: height, baseIntensity: baseLightIntensity, seed: Math.random() * 100 }; candleShadowSources.push(candle); scene.add(candle); } function createFlame() { const flame = new THREE.Group(); const outer = new THREE.Mesh( createFlameGeometry(0.07, 0.2, 28, 18), createFlameMaterial({ base: new THREE.Color(0x342100), middle: new THREE.Color(0xff9a20), tip: new THREE.Color(0xffd271), opacity: 0.58, noiseScale: 16, displacement: 0.015 }) ); const core = new THREE.Mesh( createFlameGeometry(0.034, 0.145, 24, 14), createFlameMaterial({ base: new THREE.Color(0x203258), middle: new THREE.Color(0xfff2a8), tip: new THREE.Color(0xffb54a), opacity: 0.82, noiseScale: 21, displacement: 0.008 }) ); outer.renderOrder = 4; core.renderOrder = 5; flame.add(outer, core); return flame; } function createFlameGeometry(radius, height, radialSegments, heightSegments) { const positions = []; const normals = []; const uvs = []; const indices = []; for (let y = 0; y <= heightSegments; y += 1) { const v = y / heightSegments; const taper = Math.sin(Math.PI * Math.pow(v, 0.72)); const point = Math.pow(v, 3.2); const ringRadius = radius * taper * (1 - point * 0.82) * (0.72 + v * 0.5); const yPos = (v - 0.18) * height; for (let x = 0; x <= radialSegments; x += 1) { const u = x / radialSegments; const angle = u * Math.PI * 2; const lean = v * v * 0.018; positions.push( Math.cos(angle) * ringRadius + lean, yPos, Math.sin(angle) * ringRadius ); normals.push(Math.cos(angle), 0.35, Math.sin(angle)); uvs.push(u, v); } } for (let y = 0; y < heightSegments; y += 1) { for (let x = 0; x < radialSegments; x += 1) { const a = y * (radialSegments + 1) + x; const b = a + 1; const c = a + radialSegments + 1; const d = c + 1; indices.push(a, c, b, b, c, d); } } const geometry = new THREE.BufferGeometry(); geometry.setIndex(indices); geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)); geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)); geometry.computeVertexNormals(); return geometry; } function createFlameMaterial({ base, middle, tip, opacity, noiseScale, displacement }) { return new THREE.ShaderMaterial({ transparent: true, depthWrite: false, depthTest: true, blending: THREE.AdditiveBlending, uniforms: { time: { value: 0 }, baseColor: { value: base }, middleColor: { value: middle }, tipColor: { value: tip }, flameOpacity: { value: opacity }, noiseScale: { value: noiseScale }, displacement: { value: displacement } }, vertexShader: ` uniform float time; uniform float noiseScale; uniform float displacement; varying vec2 vUv; varying float vHeight; float wave(vec3 p) { return sin(p.x * noiseScale + time * 7.1) * 0.5 + sin((p.z + p.y) * noiseScale * 0.73 - time * 5.4) * 0.5; } void main() { vUv = uv; vHeight = uv.y; vec3 transformed = position; float flutter = wave(position) * displacement * smoothstep(0.08, 1.0, uv.y); transformed.x += flutter; transformed.z += sin(time * 4.9 + position.y * 23.0) * displacement * 0.35 * uv.y; gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0); } `, fragmentShader: ` uniform float time; uniform vec3 baseColor; uniform vec3 middleColor; uniform vec3 tipColor; uniform float flameOpacity; varying vec2 vUv; varying float vHeight; float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } void main() { float alphaShape = smoothstep(0.0, 0.18, vHeight) * smoothstep(1.0, 0.58, vHeight); float flicker = 0.88 + sin(time * 9.0 + hash(vUv) * 6.2831) * 0.08; vec3 lower = mix(baseColor, middleColor, smoothstep(0.08, 0.5, vHeight)); vec3 color = mix(lower, tipColor, smoothstep(0.54, 1.0, vHeight)); gl_FragColor = vec4(color * flicker, alphaShape * flameOpacity); } ` }); } function createWaxMaterial(height) { const material = new THREE.MeshPhysicalMaterial({ color: 0xffdfaa, roughness: 0.52, metalness: 0, transmission: 0.46, thickness: 0.42, attenuationColor: 0xffb76a, attenuationDistance: 0.62, ior: 1.42, emissive: 0xffb56a, emissiveIntensity: 0.055, envMapIntensity: 0 }); material.customProgramCacheKey = () => `book-lab-wax-flame-aware-sss-${height.toFixed(3)}`; material.onBeforeCompile = (shader) => { material.userData.shader = shader; shader.uniforms.waxHeight = { value: height }; shader.uniforms.waxFlameWorldPosition = { value: new THREE.Vector3(0, height + 0.12, 0) }; shader.uniforms.waxBodyWorldPosition = { value: new THREE.Vector3() }; shader.uniforms.waxLightPower = { value: 1 }; shader.vertexShader = shader.vertexShader .replace( '#include ', '#include \nvarying float vWaxLocalY;\nvarying vec3 vWaxWorldPosition;\nvarying vec3 vWaxWorldNormal;' ) .replace( '#include ', '#include \nvWaxLocalY = position.y;' ) .replace( '#include ', '#include \nvWaxWorldNormal = normalize(mat3(modelMatrix) * objectNormal);' ) .replace( '#include ', 'vWaxWorldPosition = (modelMatrix * vec4(transformed, 1.0)).xyz;\n#include ' ); shader.fragmentShader = shader.fragmentShader .replace( '#include ', `#include uniform float waxHeight; uniform vec3 waxFlameWorldPosition; uniform vec3 waxBodyWorldPosition; uniform float waxLightPower; varying float vWaxLocalY; varying vec3 vWaxWorldPosition; varying vec3 vWaxWorldNormal;` ) .replace( '#include ', `float waxTop = smoothstep(waxHeight * 0.08, waxHeight * 0.5, vWaxLocalY); float waxRim = pow(1.0 - abs(dot(normalize(normal), normalize(geometryViewDir))), 2.2); float waxCore = smoothstep(-waxHeight * 0.45, waxHeight * 0.3, vWaxLocalY); float waxFlameCup = smoothstep(waxHeight * 0.28, waxHeight * 0.52, vWaxLocalY); vec3 waxToFlame = waxFlameWorldPosition - vWaxWorldPosition; float waxFlameDistance = length(waxToFlame); vec3 waxLightDir = normalize(waxToFlame); vec3 waxWorldNormal = normalize(vWaxWorldNormal); float waxNearFlame = 1.0 - smoothstep(0.06, 0.58, waxFlameDistance); float waxUpperBody = smoothstep(waxBodyWorldPosition.y + waxHeight * 0.38, waxBodyWorldPosition.y + waxHeight * 0.92, vWaxWorldPosition.y); float waxForwardScatter = pow(max(dot(waxWorldNormal, waxLightDir), 0.0), 0.62); float waxBackScatter = pow(max(dot(-waxWorldNormal, waxLightDir), 0.0), 1.5); float waxSideGlow = pow(max(1.0 - abs(dot(waxWorldNormal, waxLightDir)), 0.0), 1.15); float waxSubsurface = (waxNearFlame * (0.56 + waxForwardScatter * 0.42) + waxBackScatter * 0.25 + waxSideGlow * 0.18) * waxUpperBody * waxLightPower; float waxCavityGlow = waxFlameCup * waxNearFlame * waxLightPower * 0.75; vec3 waxScatter = vec3(1.0, 0.48, 0.19) * (waxTop * 0.11 + waxRim * waxCore * 0.09 + waxFlameCup * 0.08 + waxSubsurface * 0.46 + waxCavityGlow * 0.36); outgoingLight += waxScatter; #include ` ); }; return material; } function configureTableShader(material) { material.customProgramCacheKey = () => 'book-lab-table-planar-environment-reflection-v7'; material.onBeforeCompile = (shader) => { tableShader = shader; shader.uniforms.roomReflectionMap = { value: tableRoomReflectionTexture }; shader.uniforms.sceneReflectionMap = { value: tableReflectionTarget.texture }; shader.uniforms.sceneReflectionMatrix = { value: tableReflectionMatrix }; shader.uniforms.tableDustMap = { value: tableDustTexture }; shader.uniforms.tableGreaseMap = { value: tableGreaseTexture }; shader.uniforms.candleBodyPositions = { value: [new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3()] }; shader.uniforms.candleFlamePositions = { value: [new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3()] }; shader.uniforms.candleBodyData = { value: [new THREE.Vector2(), new THREE.Vector2(), new THREE.Vector2()] }; shader.uniforms.bookShadowMaps = { value: bookShadowTargets.map((target) => target.texture) }; shader.uniforms.bookShadowMatrices = { value: bookShadowMatrices }; shader.uniforms.bookShadowMapTexelSize = { value: new THREE.Vector2(1 / bookShadowMapSize, 1 / bookShadowMapSize) }; shader.uniforms.tableDebugMode = { value: tableDebugMode }; shader.vertexShader = shader.vertexShader .replace( '#include ', '#include \nuniform mat4 sceneReflectionMatrix;\nvarying vec3 vTableWorldPosition;\nvarying vec4 vSceneReflectionCoord;' ) .replace( '#include ', 'vec4 tableWorldPosition = modelMatrix * vec4(transformed, 1.0);\nvTableWorldPosition = tableWorldPosition.xyz;\nvSceneReflectionCoord = sceneReflectionMatrix * tableWorldPosition;\n#include ' ); shader.fragmentShader = shader.fragmentShader .replace( '#include ', `#include uniform sampler2D roomReflectionMap; uniform sampler2D sceneReflectionMap; uniform sampler2D tableDustMap; uniform sampler2D tableGreaseMap; uniform mat4 sceneReflectionMatrix; uniform vec3 candleBodyPositions[3]; uniform vec3 candleFlamePositions[3]; uniform vec2 candleBodyData[3]; uniform sampler2D bookShadowMaps[3]; uniform mat4 bookShadowMatrices[3]; uniform vec2 bookShadowMapTexelSize; uniform int tableDebugMode; varying vec3 vTableWorldPosition; varying vec4 vSceneReflectionCoord; vec2 tableDustUvFromWorld(vec3 worldPosition) { return clamp(vec2(worldPosition.x / 9.8 + 0.5, 0.5 - worldPosition.z / 6.6), vec2(0.0), vec2(1.0)); } float tableTopMaskFromWorld(vec3 worldPosition) { return smoothstep(-0.095, -0.025, worldPosition.y); } float tableDustFromWorld(vec3 worldPosition) { return texture2D(tableDustMap, tableDustUvFromWorld(worldPosition)).r * tableTopMaskFromWorld(worldPosition); } float tableGreaseFromWorld(vec3 worldPosition) { return texture2D(tableGreaseMap, tableDustUvFromWorld(worldPosition)).r * tableTopMaskFromWorld(worldPosition); } vec3 rotateRoomReflection(vec3 dir) { const float yaw = 0.42; float s = sin(yaw); float c = cos(yaw); return normalize(vec3(c * dir.x - s * dir.z, dir.y, s * dir.x + c * dir.z)); } vec3 sampleRoomReflection(vec3 dir) { dir = rotateRoomReflection(normalize(dir)); float u = 0.5 + atan(dir.z, dir.x) / 6.28318530718; float v = 0.5 + asin(clamp(dir.y, -1.0, 1.0)) / 3.14159265359; return texture2D(roomReflectionMap, vec2(u, v)).rgb; } vec3 sampleRoughRoomReflection(vec3 dir) { vec3 tangent = normalize(cross(vec3(0.0, 1.0, 0.0), dir)); if (length(tangent) < 0.01) tangent = vec3(1.0, 0.0, 0.0); vec3 bitangent = normalize(cross(dir, tangent)); return sampleRoomReflection(dir) * 0.42 + sampleRoomReflection(dir + tangent * 0.035) * 0.18 + sampleRoomReflection(dir - tangent * 0.035) * 0.18 + sampleRoomReflection(dir + bitangent * 0.026) * 0.11 + sampleRoomReflection(dir - bitangent * 0.026) * 0.11; } float candleBodyOcclusion(vec3 point, vec3 flame, vec3 body, vec2 bodyData, float selfLight) { vec3 segment = point - flame; vec2 segmentXZ = segment.xz; vec2 flameToBody = flame.xz - body.xz; float radius = bodyData.x; float a = max(dot(segmentXZ, segmentXZ), 0.000001); float b = 2.0 * dot(flameToBody, segmentXZ); float c = dot(flameToBody, flameToBody) - radius * radius; float discriminant = b * b - 4.0 * a * c; float nearestT = clamp(-dot(flameToBody, segmentXZ) / a, 0.0, 1.0); vec2 nearestXZ = flameToBody + segmentXZ * nearestT; float nearestDistance = length(nearestXZ); float hitT = nearestT; float cylinderHit = 0.0; if (discriminant > 0.0) { float sqrtDisc = sqrt(discriminant); float t0 = (-b - sqrtDisc) / (2.0 * a); float t1 = (-b + sqrtDisc) / (2.0 * a); float validT0 = step(0.0, t0) * step(t0, 1.0); float validT1 = step(0.0, t1) * step(t1, 1.0); hitT = mix(hitT, t0, validT0); hitT = mix(hitT, t1, (1.0 - validT0) * validT1); cylinderHit = max(validT0, validT1); } float closestY = flame.y + segment.y * hitT; float bodyTop = body.y + bodyData.y; float vertical = smoothstep(body.y - 0.045, body.y + 0.08, closestY) * (1.0 - smoothstep(bodyTop - 0.08, bodyTop + 0.045, closestY)); float segmentLength = length(segment); float penumbraWidth = radius * (0.45 + segmentLength * 0.12); float exactHit = cylinderHit; float softHit = 1.0 - smoothstep(radius, radius + penumbraWidth, nearestDistance); float selfShadowLimiter = mix(1.0, 0.06, selfLight); float waxExitHeight = smoothstep(body.y, bodyTop, closestY); float waxTransmission = 0.48 + 0.34 * waxExitHeight; float bodyOpacity = 1.0 - waxTransmission * mix(0.62, 0.86, selfLight); return clamp(max(exactHit, softHit * 0.72) * vertical * selfShadowLimiter * bodyOpacity, 0.0, 0.42); } float candleProjectedShadowField(vec3 point) { float projectedShadow = 0.0; for (int bodyIndex = 0; bodyIndex < 3; bodyIndex++) { for (int flameIndex = 0; flameIndex < 3; flameIndex++) { float selfLight = bodyIndex == flameIndex ? 1.0 : 0.0; projectedShadow = max(projectedShadow, candleBodyOcclusion(point, candleFlamePositions[flameIndex], candleBodyPositions[bodyIndex], candleBodyData[bodyIndex], selfLight)); } } return clamp(projectedShadow, 0.0, 0.46); } float candlePlanarShadowLobe(vec3 point, vec3 flame, vec3 body, vec2 bodyData, float selfLight) { vec2 lightToBody = body.xz - flame.xz; float lightDistance = length(lightToBody); vec2 direction = lightDistance > 0.012 ? lightToBody / lightDistance : normalize(vec2(0.42, 0.91)); vec2 perpendicular = vec2(-direction.y, direction.x); vec2 delta = point.xz - body.xz; float along = dot(delta, direction); float side = abs(dot(delta, perpendicular)); float radius = bodyData.x; float softContact = 1.0 - smoothstep(radius * 0.72, radius * 3.4, length(delta)); float lobeLength = mix(radius * 3.4, radius * (4.8 + lightDistance * 0.92), 1.0 - selfLight); float lobeWidth = radius * (1.6 + max(along, 0.0) * mix(0.72, 0.46, selfLight)); float frontGate = smoothstep(-radius * 0.42, radius * 0.34, along); float distanceFade = 1.0 - smoothstep(radius * 0.7, lobeLength, along); float sideFade = 1.0 - smoothstep(lobeWidth * 0.54, lobeWidth, side); float directional = frontGate * distanceFade * sideFade; float heightFade = smoothstep(body.y - 0.02, body.y + bodyData.y * 0.72, flame.y); float waxTransmission = mix(0.54, 0.78, selfLight); float strength = mix(0.32, 0.2, selfLight) * heightFade * (1.0 - waxTransmission * 0.48); return clamp(max(softContact * 0.2, directional * strength), 0.0, 0.38); } float candlePlanarShadowField(vec3 point) { float shadow = 0.0; for (int bodyIndex = 0; bodyIndex < 3; bodyIndex++) { for (int flameIndex = 0; flameIndex < 3; flameIndex++) { float selfLight = bodyIndex == flameIndex ? 1.0 : 0.0; shadow = max(shadow, candlePlanarShadowLobe(point, candleFlamePositions[flameIndex], candleBodyPositions[bodyIndex], candleBodyData[bodyIndex], selfLight)); } } return clamp(shadow, 0.0, 0.5); } float bookUnpackRGBADepth(vec4 packedDepth) { const vec4 unpackFactors = vec4( 1.0 / (256.0 * 256.0 * 256.0), 1.0 / (256.0 * 256.0), 1.0 / 256.0, 1.0 ); return dot(packedDepth, unpackFactors); } float bookShadowCompare(vec4 packedDepth, float currentDepth) { float closestDepth = bookUnpackRGBADepth(packedDepth); return smoothstep(0.001, 0.018, currentDepth - closestDepth - 0.0018); } float bookShadowSample0(vec4 shadowCoord) { vec3 coord = shadowCoord.xyz / max(shadowCoord.w, 0.0001); float inBounds = step(0.0, coord.x) * step(0.0, coord.y) * step(coord.x, 1.0) * step(coord.y, 1.0) * step(0.0, coord.z) * step(coord.z, 1.0); if (inBounds < 0.5) return 0.0; float shadow = 0.0; for (int x = -1; x <= 1; x++) { for (int y = -1; y <= 1; y++) { vec2 offset = vec2(float(x), float(y)) * bookShadowMapTexelSize * 1.65; shadow += bookShadowCompare(texture2D(bookShadowMaps[0], coord.xy + offset), coord.z); } } return clamp(shadow / 9.0, 0.0, 1.0) * inBounds; } float bookShadowSample1(vec4 shadowCoord) { vec3 coord = shadowCoord.xyz / max(shadowCoord.w, 0.0001); float inBounds = step(0.0, coord.x) * step(0.0, coord.y) * step(coord.x, 1.0) * step(coord.y, 1.0) * step(0.0, coord.z) * step(coord.z, 1.0); if (inBounds < 0.5) return 0.0; float shadow = 0.0; for (int x = -1; x <= 1; x++) { for (int y = -1; y <= 1; y++) { vec2 offset = vec2(float(x), float(y)) * bookShadowMapTexelSize * 1.65; shadow += bookShadowCompare(texture2D(bookShadowMaps[1], coord.xy + offset), coord.z); } } return clamp(shadow / 9.0, 0.0, 1.0) * inBounds; } float bookShadowSample2(vec4 shadowCoord) { vec3 coord = shadowCoord.xyz / max(shadowCoord.w, 0.0001); float inBounds = step(0.0, coord.x) * step(0.0, coord.y) * step(coord.x, 1.0) * step(coord.y, 1.0) * step(0.0, coord.z) * step(coord.z, 1.0); if (inBounds < 0.5) return 0.0; float shadow = 0.0; for (int x = -1; x <= 1; x++) { for (int y = -1; y <= 1; y++) { vec2 offset = vec2(float(x), float(y)) * bookShadowMapTexelSize * 1.65; shadow += bookShadowCompare(texture2D(bookShadowMaps[2], coord.xy + offset), coord.z); } } return clamp(shadow / 9.0, 0.0, 1.0) * inBounds; } float bookMeshShadowField(vec3 point) { float shadow0 = bookShadowSample0(bookShadowMatrices[0] * vec4(point, 1.0)); float shadow1 = bookShadowSample1(bookShadowMatrices[1] * vec4(point, 1.0)); float shadow2 = bookShadowSample2(bookShadowMatrices[2] * vec4(point, 1.0)); return clamp(max(max(shadow0, shadow1), shadow2) * 0.62, 0.0, 0.62); }` ) .replace( '#include ', `#include float tableSpecularGrease = tableGreaseFromWorld(vTableWorldPosition); float tableSpecularDust = tableDustFromWorld(vTableWorldPosition) * (1.0 - smoothstep(0.025, 0.18, tableSpecularGrease) * 0.82); float tableSpecularDustFilm = smoothstep(0.006, 0.034, tableSpecularDust); float tableSpecularGreaseFilm = smoothstep(0.016, 0.14, tableSpecularGrease); roughnessFactor = clamp(mix(roughnessFactor, 0.84, tableSpecularDustFilm * 0.32), 0.04, 1.0); roughnessFactor = clamp(mix(roughnessFactor, 0.34, tableSpecularGreaseFilm * 0.3), 0.04, 1.0);` ) .replace( '#include ', `vec3 viewDirWorld = normalize(cameraPosition - vTableWorldPosition); vec3 tableNormalWorld = normalize(vec3(normal.x * 0.18, 1.0, normal.z * 0.18)); vec3 reflectedDir = reflect(-viewDirWorld, tableNormalWorld); vec3 roomReflection = sampleRoughRoomReflection(reflectedDir); roomReflection = pow(max(roomReflection, vec3(0.0)), vec3(0.78)); roomReflection *= vec3(0.88, 0.7, 0.5); vec2 sceneReflectionUv = vSceneReflectionCoord.xy / max(vSceneReflectionCoord.w, 0.0001); float sceneReflectionInBounds = step(0.0, sceneReflectionUv.x) * step(0.0, sceneReflectionUv.y) * step(sceneReflectionUv.x, 1.0) * step(sceneReflectionUv.y, 1.0); float sceneReflectionEdge = smoothstep(0.0, 0.08, sceneReflectionUv.x) * smoothstep(0.0, 0.08, sceneReflectionUv.y) * smoothstep(0.0, 0.08, 1.0 - sceneReflectionUv.x) * smoothstep(0.0, 0.08, 1.0 - sceneReflectionUv.y); vec3 sceneReflection = texture2D(sceneReflectionMap, sceneReflectionUv).rgb; sceneReflection = pow(max(sceneReflection, vec3(0.0)), vec3(0.88)) * sceneReflectionInBounds * sceneReflectionEdge; float grease = tableGreaseFromWorld(vTableWorldPosition); float greaseDustWipe = smoothstep(0.016, 0.13, grease); float dust = tableDustFromWorld(vTableWorldPosition) * (1.0 - greaseDustWipe * 0.82); float dustFilm = smoothstep(0.006, 0.034, dust); float greaseFilm = smoothstep(0.016, 0.14, grease); float reflectionCleanliness = 1.0 - dustFilm * 0.2 - greaseFilm * 0.06; vec3 dustBlurredSceneReflection = sceneReflection * 0.78 + roomReflection * 0.055; vec3 greaseBlurredSceneReflection = sceneReflection * 0.7 + roomReflection * 0.18; vec3 dustAwareSceneReflection = mix(sceneReflection, dustBlurredSceneReflection, dustFilm); dustAwareSceneReflection = mix(dustAwareSceneReflection, greaseBlurredSceneReflection, greaseFilm); vec3 combinedReflection = (roomReflection * 0.14 + dustAwareSceneReflection * 0.86) * reflectionCleanliness; float fresnel = pow(1.0 - max(dot(viewDirWorld, tableNormalWorld), 0.0), 1.85); float tableReflectionMask = smoothstep(-0.095, -0.025, vTableWorldPosition.y); vec3 reflectedSurface = combinedReflection * (0.64 + fresnel * 0.36); float reflectedLuma = dot(reflectedSurface, vec3(0.299, 0.587, 0.114)); reflectedSurface = mix(reflectedSurface, vec3(reflectedLuma), dustFilm * 0.42); float candleProjectedShadow = max( max(candleProjectedShadowField(vTableWorldPosition), candlePlanarShadowField(vTableWorldPosition)), bookMeshShadowField(vTableWorldPosition) ) * tableReflectionMask; float candleOcclusion = clamp(candleProjectedShadow * 1.46, 0.0, 0.82); vec3 normalDebug = normalize(normal) * 0.5 + 0.5; outgoingLight = mix(outgoingLight, reflectedSurface, tableReflectionMask * (0.16 + fresnel * 0.28 + greaseFilm * 0.065) * reflectionCleanliness); outgoingLight += tableReflectionMask * roomReflection * 0.004 * reflectionCleanliness; outgoingLight += tableReflectionMask * dustFilm * vec3(0.008, 0.0085, 0.009) * (0.22 + fresnel * 0.62); outgoingLight += tableReflectionMask * greaseFilm * vec3(0.018, 0.013, 0.007) * (0.28 + fresnel * 0.58); outgoingLight *= mix(vec3(1.0), vec3(0.19, 0.15, 0.115), candleOcclusion); if (tableDebugMode == 1) outgoingLight = vec3(candleProjectedShadow); if (tableDebugMode == 2) outgoingLight = vec3(dust); if (tableDebugMode == 3) outgoingLight = normalDebug; if (tableDebugMode == 4) outgoingLight = roomReflection; if (tableDebugMode == 5) outgoingLight = sceneReflection; if (tableDebugMode == 6) outgoingLight = vec3(tableReflectionMask); if (tableDebugMode == 8) outgoingLight = vec3(grease); #include ` ); }; } function buildBook() { book.position.set(0, 0.03, 0); book.rotation.y = 0; const coverW = 1.76; const coverH = 2.42; const coverT = 0.09; const pageW = 1.56; const pageH = 2.22; const stackT = 0.22; const spine = new THREE.Mesh(new THREE.BoxGeometry(0.28, 0.24, coverH), materials.leather); spine.position.set(0, -0.015, 0); spine.castShadow = true; spine.receiveShadow = true; book.add(spine); const leftCover = makeBox(coverW, coverT, coverH, materials.leather); leftCover.position.set(-coverW / 2 - 0.08, -0.055, 0); leftCover.rotation.z = 0.035; book.add(leftCover); const rightCover = makeBox(coverW, coverT, coverH, materials.leather); rightCover.position.set(coverW / 2 + 0.08, -0.055, 0); rightCover.rotation.z = -0.035; book.add(rightCover); const leftStack = makeBox(pageW, stackT, pageH, materials.pageBlock); leftStack.position.set(-pageW / 2 - 0.075, 0.045, 0); leftStack.rotation.z = 0.018; book.add(leftStack); const rightStack = makeBox(pageW, stackT, pageH, materials.pageBlock); rightStack.position.set(pageW / 2 + 0.075, 0.045, 0); rightStack.rotation.z = -0.018; book.add(rightStack); addPageEdgeLines(leftStack.position.x, -1, pageW, pageH, stackT); addPageEdgeLines(rightStack.position.x, 1, pageW, pageH, stackT); const leftPage = new THREE.Mesh(createPageGeometry(-1, pageW, pageH), materials.leftPage); leftPage.position.y = 0.2; leftPage.castShadow = true; leftPage.receiveShadow = false; book.add(leftPage); const rightPage = new THREE.Mesh(createPageGeometry(1, pageW, pageH), materials.rightPage); rightPage.position.y = 0.2; rightPage.castShadow = true; rightPage.receiveShadow = false; book.add(rightPage); const gutterShadow = new THREE.Mesh( new THREE.BoxGeometry(0.045, 0.028, pageH * 0.96), new THREE.MeshStandardMaterial({ color: 0x5f3d20, roughness: 0.98 }) ); gutterShadow.position.set(0, 0.205, 0); book.add(gutterShadow); } function makeBox(width, height, depth, material) { const mesh = new THREE.Mesh(new THREE.BoxGeometry(width, height, depth), material); mesh.castShadow = true; mesh.receiveShadow = true; return mesh; } function addPageEdgeLines(centerX, side, pageW, pageH, stackT) { const lineMaterial = new THREE.LineBasicMaterial({ color: 0x8e6840, transparent: true, opacity: 0.34 }); const edgeX = centerX + side * pageW * 0.5; for (let i = 0; i < 22; i += 1) { const y = -0.06 + (i / 21) * stackT; const zInset = 0.04 + (i % 3) * 0.006; const points = [ new THREE.Vector3(edgeX, y, -pageH / 2 + zInset), new THREE.Vector3(edgeX + side * 0.012, y + 0.002, pageH / 2 - zInset) ]; book.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), lineMaterial)); } } function createPageGeometry(side, width, height) { const columns = 36; const rows = 42; const positions = []; const uvs = []; const indices = []; for (let y = 0; y <= rows; y += 1) { const v = y / rows; const z = (v - 0.5) * height; for (let x = 0; x <= columns; x += 1) { const u = x / columns; const outward = u * width; const pageX = side * (0.055 + outward); const gutterLift = 0.055 * Math.pow(1 - u, 2.1); const edgeFall = -0.012 * Math.pow(u, 1.7); const centerSag = -0.014 * Math.sin(Math.PI * v) * Math.sin(Math.PI * u); const ripple = 0.004 * Math.sin(v * Math.PI * 4 + side * 0.7) * (1 - Math.abs(u - 0.5)); positions.push(pageX, gutterLift + edgeFall + centerSag, z + ripple); uvs.push(side < 0 ? 1 - u : u, 1 - v); } } 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); } } 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 createPageCanvas(side) { const canvas = document.createElement('canvas'); canvas.width = 3600; canvas.height = 5000; const ctx = canvas.getContext('2d'); ctx.fillStyle = '#f5dfab'; ctx.fillRect(0, 0, canvas.width, canvas.height); const grain = ctx.createImageData(canvas.width, canvas.height); for (let i = 0; i < grain.data.length; i += 4) { const n = 234 + Math.floor(Math.random() * 24); grain.data[i] = n; grain.data[i + 1] = Math.min(255, n - 8); grain.data[i + 2] = Math.max(0, n - 45); grain.data[i + 3] = 18; } ctx.putImageData(grain, 0, 0); const shade = ctx.createLinearGradient(0, 0, canvas.width, 0); shade.addColorStop(0, 'rgba(93, 55, 24, 0.18)'); shade.addColorStop(side === 'left' ? 0.85 : 0.15, 'rgba(255, 255, 255, 0)'); shade.addColorStop(1, 'rgba(85, 49, 21, 0.12)'); ctx.fillStyle = shade; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.strokeStyle = 'rgba(92, 63, 31, 0.18)'; ctx.lineWidth = 4; for (let y = 580; y < canvas.height - 380; y += 176) { ctx.beginPath(); ctx.moveTo(340, y); ctx.lineTo(canvas.width - 320, y + Math.sin(y * 0.02) * 8); ctx.stroke(); } ctx.fillStyle = inkColor; ctx.textBaseline = 'top'; if (side === 'left') { drawCentered(ctx, 'Georg Tomitsch', 510, 88); drawCentered(ctx, 'Eibenreith', 660, 184); drawCentered(ctx, 'Ein Kaiserpunk Abenteuer', 910, 108); drawCentered(ctx, 'speech | autoplay | speed | new game | save | load | options', 1220, 68); drawCentered(ctx, 'click on page or press spacebar to fast forward text animation', 1440, 68); } else { drawParagraph(ctx, 'Click on new game or load to start the game', 420, 620, canvas.width - 840, 148, 1.35); } return canvas; } 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 drawCentered(ctx, text, y, size) { ctx.font = `${size}px Georgia, "Times New Roman", serif`; ctx.textAlign = 'center'; ctx.fillText(text, ctx.canvas.width / 2, y); } function drawParagraph(ctx, text, x, y, width, size, lineHeight) { ctx.font = `${size}px Georgia, "Times New Roman", serif`; ctx.textAlign = 'left'; const words = text.split(/\s+/); let line = ''; words.forEach((word) => { const test = line ? `${line} ${word}` : word; if (ctx.measureText(test).width > width && line) { ctx.fillText(line, x, y); line = word; y += size * lineHeight; } else { line = test; } }); if (line) ctx.fillText(line, x, y); } 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.floor(width * reflectionScale); const reflectionHeight = Math.floor(height * reflectionScale); reflectionTargetSize.set(reflectionWidth, reflectionHeight); tableReflectionTarget.setSize( reflectionTargetSize.x, reflectionTargetSize.y ); } function installCameraControls() { canvas.addEventListener('pointerdown', (event) => { cameraRig.dragging = 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) => { cameraRig.dragging = false; canvas.style.cursor = 'grab'; canvas.releasePointerCapture(event.pointerId); }); canvas.addEventListener('pointercancel', () => { cameraRig.dragging = false; canvas.style.cursor = 'grab'; }); canvas.addEventListener('wheel', (event) => { 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 (['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; 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; tableMesh.visible = true; } 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); } }); updateCandleShadowUniforms(); updateBookShadowMaps(); updateTableReflection(); if (composer) { composer.render(); } else { renderer.render(scene, camera); } window.BookLabDebug.renderedFrames += 1; window.BookLabDebug.ready = true; }