Files
ai.interactive.fiction/public/js/webgl-book-lab.js
T

1242 lines
50 KiB
JavaScript

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 <common>',
'#include <common>\nvarying float vWaxLocalY;\nvarying vec3 vWaxWorldPosition;\nvarying vec3 vWaxWorldNormal;'
)
.replace(
'#include <begin_vertex>',
'#include <begin_vertex>\nvWaxLocalY = position.y;'
)
.replace(
'#include <defaultnormal_vertex>',
'#include <defaultnormal_vertex>\nvWaxWorldNormal = normalize(mat3(modelMatrix) * objectNormal);'
)
.replace(
'#include <project_vertex>',
'vWaxWorldPosition = (modelMatrix * vec4(transformed, 1.0)).xyz;\n#include <project_vertex>'
);
shader.fragmentShader = shader.fragmentShader
.replace(
'#include <common>',
`#include <common>
uniform float waxHeight;
uniform vec3 waxFlameWorldPosition;
uniform vec3 waxBodyWorldPosition;
uniform float waxLightPower;
varying float vWaxLocalY;
varying vec3 vWaxWorldPosition;
varying vec3 vWaxWorldNormal;`
)
.replace(
'#include <opaque_fragment>',
`float waxTop = smoothstep(waxHeight * 0.08, waxHeight * 0.5, vWaxLocalY);
float waxRim = pow(1.0 - abs(dot(normalize(normal), normalize(geometryViewDir))), 2.2);
float waxCore = smoothstep(-waxHeight * 0.45, waxHeight * 0.3, vWaxLocalY);
float waxFlameCup = smoothstep(waxHeight * 0.28, waxHeight * 0.52, vWaxLocalY);
vec3 waxToFlame = waxFlameWorldPosition - vWaxWorldPosition;
float waxFlameDistance = length(waxToFlame);
vec3 waxLightDir = normalize(waxToFlame);
vec3 waxWorldNormal = normalize(vWaxWorldNormal);
float waxNearFlame = 1.0 - smoothstep(0.06, 0.58, waxFlameDistance);
float waxUpperBody = smoothstep(waxBodyWorldPosition.y + waxHeight * 0.38, waxBodyWorldPosition.y + waxHeight * 0.92, vWaxWorldPosition.y);
float waxForwardScatter = pow(max(dot(waxWorldNormal, waxLightDir), 0.0), 0.62);
float waxBackScatter = pow(max(dot(-waxWorldNormal, waxLightDir), 0.0), 1.5);
float waxSideGlow = pow(max(1.0 - abs(dot(waxWorldNormal, waxLightDir)), 0.0), 1.15);
float waxSubsurface = (waxNearFlame * (0.56 + waxForwardScatter * 0.42) + waxBackScatter * 0.25 + waxSideGlow * 0.18) * waxUpperBody * waxLightPower;
float waxCavityGlow = waxFlameCup * waxNearFlame * waxLightPower * 0.75;
vec3 waxScatter = vec3(1.0, 0.48, 0.19) * (waxTop * 0.11 + waxRim * waxCore * 0.09 + waxFlameCup * 0.08 + waxSubsurface * 0.46 + waxCavityGlow * 0.36);
outgoingLight += waxScatter;
#include <opaque_fragment>`
);
};
return material;
}
function configureTableShader(material) {
material.customProgramCacheKey = () => 'book-lab-table-planar-environment-reflection-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 <common>',
'#include <common>\nuniform mat4 sceneReflectionMatrix;\nvarying vec3 vTableWorldPosition;\nvarying vec4 vSceneReflectionCoord;'
)
.replace(
'#include <project_vertex>',
'vec4 tableWorldPosition = modelMatrix * vec4(transformed, 1.0);\nvTableWorldPosition = tableWorldPosition.xyz;\nvSceneReflectionCoord = sceneReflectionMatrix * tableWorldPosition;\n#include <project_vertex>'
);
shader.fragmentShader = shader.fragmentShader
.replace(
'#include <common>',
`#include <common>
uniform sampler2D roomReflectionMap;
uniform sampler2D sceneReflectionMap;
uniform sampler2D tableDustMap;
uniform 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 <opaque_fragment>',
`vec3 viewDirWorld = normalize(cameraPosition - vTableWorldPosition);
vec3 tableNormalWorld = normalize(vec3(normal.x * 0.18, 1.0, normal.z * 0.18));
vec3 reflectedDir = reflect(-viewDirWorld, tableNormalWorld);
vec3 roomReflection = sampleRoughRoomReflection(reflectedDir);
roomReflection = pow(max(roomReflection, vec3(0.0)), vec3(0.78));
roomReflection *= vec3(0.88, 0.7, 0.5);
vec2 sceneReflectionUv = vSceneReflectionCoord.xy / max(vSceneReflectionCoord.w, 0.0001);
float sceneReflectionInBounds = step(0.0, sceneReflectionUv.x) * step(0.0, sceneReflectionUv.y) *
step(sceneReflectionUv.x, 1.0) * step(sceneReflectionUv.y, 1.0);
float sceneReflectionEdge = smoothstep(0.0, 0.08, sceneReflectionUv.x) *
smoothstep(0.0, 0.08, sceneReflectionUv.y) *
smoothstep(0.0, 0.08, 1.0 - sceneReflectionUv.x) *
smoothstep(0.0, 0.08, 1.0 - sceneReflectionUv.y);
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 <opaque_fragment>`
);
};
}
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);
}