import * as THREE from 'https://esm.sh/three@0.165.0'; 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 }; 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 = `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 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; const tableTopY = -0.02; const tableReflectionTarget = new THREE.WebGLRenderTarget(1024, 576, { colorSpace: THREE.SRGBColorSpace, depthBuffer: true, stencilBuffer: false }); tableReflectionTarget.texture.colorSpace = THREE.SRGBColorSpace; tableReflectionTarget.texture.minFilter = THREE.LinearFilter; tableReflectionTarget.texture.magFilter = THREE.LinearFilter; 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 reflectionForward = new THREE.Vector3(); const reflectionTarget = new THREE.Vector3(); const reflectionUp = new THREE.Vector3(); const candleShadowSources = []; const candleWorldPosition = new THREE.Vector3(); const flameWorldPosition = new THREE.Vector3(); const camera = new THREE.PerspectiveCamera(28, 1, 0.1, 80); 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); 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 = renderer.capabilities.getMaxAnisotropy(); 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 }) }; buildTable(); buildLighting(); buildBook(); loadAiRoomReflection(); window.BookLabDebug = { textures: generatedTextureCanvases, 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 = createTableNormalTexture(); tableNormal.wrapS = THREE.RepeatWrapping; tableNormal.wrapT = THREE.RepeatWrapping; tableNormal.repeat.set(2.15, 1.45); tableNormal.anisotropy = renderer.capabilities.getMaxAnisotropy(); tableDustTexture = createTableDustTexture(); tableDustTexture.wrapS = THREE.ClampToEdgeWrapping; tableDustTexture.wrapT = THREE.ClampToEdgeWrapping; tableDustTexture.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.38, metalness: 0, clearcoat: 0.54, clearcoatRoughness: 0.48, reflectivity: 0.34, 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 = true; scene.add(tableMesh); } 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; 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; candle.add(wick); const flame = createFlame(); flame.position.y = wickTopY + 0.055; 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(1024, 1024); light.shadow.bias = -0.00004; light.shadow.normalBias = 0.018; light.shadow.radius = 5; light.shadow.blurSamples = 12; 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-v2'; 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.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.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 mat4 sceneReflectionMatrix; uniform vec3 candleBodyPositions[3]; uniform vec3 candleFlamePositions[3]; uniform vec2 candleBodyData[3]; uniform int tableDebugMode; varying vec3 vTableWorldPosition; varying vec4 vSceneReflectionCoord; 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 candleContactOcclusion(vec3 point, vec3 body, vec2 bodyData) { vec2 delta = point.xz - body.xz; float base = 1.0 - smoothstep(bodyData.x * 0.72, bodyData.x * 2.55, length(delta)); return base * 0.32; } float candleContactField(vec3 point) { float contact = 0.0; for (int bodyIndex = 0; bodyIndex < 3; bodyIndex++) { contact = max(contact, candleContactOcclusion(point, candleBodyPositions[bodyIndex], candleBodyData[bodyIndex])); } return clamp(contact, 0.0, 0.36); } 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); }` ) .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); vec2 roughReflectionUv = sceneReflectionUv + normal.xz * 0.024; vec3 sceneReflection = texture2D(sceneReflectionMap, roughReflectionUv).rgb; sceneReflection = pow(max(sceneReflection, vec3(0.0)), vec3(0.88)) * sceneReflectionInBounds * sceneReflectionEdge; vec2 tableDustUv = clamp(vec2(vTableWorldPosition.x / 9.8 + 0.5, 0.5 - vTableWorldPosition.z / 6.6), vec2(0.0), vec2(1.0)); float dust = texture2D(tableDustMap, tableDustUv).r; float reflectionCleanliness = 1.0 - dust * 0.62; vec3 combinedReflection = (roomReflection * 0.18 + sceneReflection * 0.5) * 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.56 + fresnel * 0.44); float contactAo = 1.0 - smoothstep(0.0, 0.9, length(vTableWorldPosition.xz * vec2(0.34, 0.58))) * 0.16; float dustDulling = dust * tableReflectionMask; float candleContact = candleContactField(vTableWorldPosition) * tableReflectionMask; float candleProjectedShadow = candleProjectedShadowField(vTableWorldPosition) * tableReflectionMask; float candleOcclusion = clamp(candleContact + candleProjectedShadow * 0.42, 0.0, 0.48); vec3 normalDebug = normalize(normal) * 0.5 + 0.5; outgoingLight *= mix(1.0, contactAo, tableReflectionMask * 0.55); outgoingLight = mix(outgoingLight, reflectedSurface, tableReflectionMask * (0.07 + fresnel * 0.14) * reflectionCleanliness); outgoingLight += tableReflectionMask * roomReflection * 0.008 * reflectionCleanliness; outgoingLight = mix(outgoingLight, outgoingLight * vec3(0.72, 0.68, 0.6) + vec3(0.045, 0.036, 0.025), dustDulling * 0.32); outgoingLight *= mix(vec3(1.0), vec3(0.5, 0.4, 0.31), 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 == 7) outgoingLight = vec3(candleContact); #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 = 1800; canvas.height = 2500; 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 = 2; for (let y = 290; y < canvas.height - 190; y += 88) { ctx.beginPath(); ctx.moveTo(170, y); ctx.lineTo(canvas.width - 160, y + Math.sin(y * 0.02) * 4); ctx.stroke(); } ctx.fillStyle = inkColor; ctx.textBaseline = 'top'; if (side === 'left') { drawCentered(ctx, 'Georg Tomitsch', 255, 44); drawCentered(ctx, 'Eibenreith', 330, 92); drawCentered(ctx, 'Ein Kaiserpunk Abenteuer', 455, 54); drawCentered(ctx, 'speech | autoplay | speed | new game | save | load | options', 610, 34); drawCentered(ctx, 'click on page or press spacebar to fast forward text animation', 720, 34); } else { drawParagraph(ctx, 'Click on new game or load to start the game', 210, 310, canvas.width - 420, 74, 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 createTableNormalTexture() { const canvas = document.createElement('canvas'); generatedTextureCanvases.tableNormal = canvas; canvas.width = 2048; canvas.height = 2048; const ctx = canvas.getContext('2d'); const image = ctx.createImageData(canvas.width, canvas.height); for (let y = 0; y < canvas.height; y += 1) { for (let x = 0; x < canvas.width; x += 1) { const i = (y * canvas.width + x) * 4; const grain = Math.sin(x * 0.028) * 8 + Math.sin((x + y) * 0.011) * 5 + Math.sin(x * 0.11 + y * 0.007) * 2; const pore = Math.sin(y * 0.12 + Math.sin(x * 0.016) * 2.1) * 3 + (Math.random() - 0.5) * 6; image.data[i] = 128 + grain; image.data[i + 1] = 128 + pore; image.data[i + 2] = 255; image.data[i + 3] = 255; } } ctx.putImageData(image, 0, 0); const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.NoColorSpace; texture.needsUpdate = true; return texture; } function createTableDustTexture() { const canvas = document.createElement('canvas'); generatedTextureCanvases.tableDust = canvas; canvas.width = 2048; canvas.height = 2048; const ctx = canvas.getContext('2d'); ctx.fillStyle = 'rgb(10, 10, 10)'; ctx.fillRect(0, 0, canvas.width, canvas.height); const image = ctx.getImageData(0, 0, canvas.width, canvas.height); for (let y = 0; y < canvas.height; y += 1) { for (let x = 0; x < canvas.width; x += 1) { const i = (y * canvas.width + x) * 4; const nx = x / canvas.width; const ny = y / canvas.height; const edgeDust = Math.max(0, 1 - Math.min(nx, ny, 1 - nx, 1 - ny) * 9); const grain = Math.random() < 0.018 ? 110 + Math.random() * 85 : Math.random() * 18; const sweep = Math.max(0, Math.sin(nx * 32 + Math.sin(ny * 15) * 2.5) - 0.84) * 55; const pageDust = Math.exp(-Math.pow((nx - 0.5) * 2.2, 2) - Math.pow((ny - 0.5) * 1.4, 2)) * 16; const value = Math.min(255, 10 + edgeDust * 36 + grain + sweep + pageDust); image.data[i] = value; image.data[i + 1] = value; image.data[i + 2] = value; image.data[i + 3] = 255; } } ctx.putImageData(image, 0, 0); const smudge = (x, y, rx, ry, alpha) => { const gradient = ctx.createRadialGradient(x, y, 0, x, y, Math.max(rx, ry)); gradient.addColorStop(0, `rgba(220, 220, 220, ${alpha})`); gradient.addColorStop(0.42, `rgba(150, 150, 150, ${alpha * 0.32})`); gradient.addColorStop(1, 'rgba(0, 0, 0, 0)'); ctx.save(); ctx.translate(x, y); ctx.scale(rx / Math.max(rx, ry), ry / Math.max(rx, ry)); ctx.fillStyle = gradient; ctx.beginPath(); ctx.arc(0, 0, Math.max(rx, ry), 0, Math.PI * 2); ctx.fill(); ctx.restore(); }; smudge(canvas.width * 0.17, canvas.height * 0.38, 170, 70, 0.12); smudge(canvas.width * 0.83, canvas.height * 0.24, 120, 58, 0.1); smudge(canvas.width * 0.73, canvas.height * 0.76, 155, 64, 0.09); smudge(canvas.width * 0.5, canvas.height * 0.52, 260, 110, 0.055); const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.NoColorSpace; texture.minFilter = THREE.LinearMipmapLinearFilter; texture.magFilter = THREE.LinearFilter; texture.generateMipmaps = true; texture.needsUpdate = true; return texture; } 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); camera.aspect = width / height; camera.updateProjectionMatrix(); const pixelRatio = renderer.getPixelRatio(); tableReflectionTarget.setSize( Math.max(320, Math.min(1280, Math.floor(width * pixelRatio * 0.75))), Math.max(180, Math.min(720, Math.floor(height * pixelRatio * 0.75))) ); } 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 updateTableReflection() { if (!tableMesh || !tableShader) return; tableReflectionCamera.copy(camera); tableReflectionCamera.position.set( camera.position.x, tableTopY - (camera.position.y - tableTopY), camera.position.z ); camera.getWorldDirection(reflectionForward); reflectionTarget.copy(camera.position).add(reflectionForward); reflectionTarget.y = tableTopY - (reflectionTarget.y - tableTopY); reflectionUp.set(0, 1, 0).applyQuaternion(camera.quaternion); 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(); updateTableReflection(); renderer.render(scene, camera); }