1252 lines
50 KiB
JavaScript
1252 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 maxTextureAnisotropy = renderer.capabilities.getMaxAnisotropy();
|
|
const reflectionPixelRatio = Math.min(window.devicePixelRatio || 1, 2);
|
|
const reflectionTargetSize = new THREE.Vector2();
|
|
|
|
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(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 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 = 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
|
|
})
|
|
};
|
|
|
|
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(2048, 2048);
|
|
light.shadow.bias = -0.00004;
|
|
light.shadow.normalBias = 0.018;
|
|
light.shadow.radius = 7;
|
|
light.shadow.blurSamples = 16;
|
|
light.shadow.camera.near = 0.04;
|
|
light.shadow.camera.far = 5.0;
|
|
candle.add(light);
|
|
|
|
candle.userData = {
|
|
light,
|
|
flame,
|
|
wax,
|
|
waxMaterial,
|
|
waxGlow,
|
|
bodyRadius: 0.12,
|
|
bodyHeight: height,
|
|
baseIntensity: baseLightIntensity,
|
|
seed: Math.random() * 100
|
|
};
|
|
candleShadowSources.push(candle);
|
|
scene.add(candle);
|
|
}
|
|
|
|
function createFlame() {
|
|
const flame = new THREE.Group();
|
|
const outer = new THREE.Mesh(
|
|
createFlameGeometry(0.07, 0.2, 28, 18),
|
|
createFlameMaterial({
|
|
base: new THREE.Color(0x342100),
|
|
middle: new THREE.Color(0xff9a20),
|
|
tip: new THREE.Color(0xffd271),
|
|
opacity: 0.58,
|
|
noiseScale: 16,
|
|
displacement: 0.015
|
|
})
|
|
);
|
|
const core = new THREE.Mesh(
|
|
createFlameGeometry(0.034, 0.145, 24, 14),
|
|
createFlameMaterial({
|
|
base: new THREE.Color(0x203258),
|
|
middle: new THREE.Color(0xfff2a8),
|
|
tip: new THREE.Color(0xffb54a),
|
|
opacity: 0.82,
|
|
noiseScale: 21,
|
|
displacement: 0.008
|
|
})
|
|
);
|
|
outer.renderOrder = 4;
|
|
core.renderOrder = 5;
|
|
flame.add(outer, core);
|
|
return flame;
|
|
}
|
|
|
|
function createFlameGeometry(radius, height, radialSegments, heightSegments) {
|
|
const positions = [];
|
|
const normals = [];
|
|
const uvs = [];
|
|
const indices = [];
|
|
|
|
for (let y = 0; y <= heightSegments; y += 1) {
|
|
const v = y / heightSegments;
|
|
const taper = Math.sin(Math.PI * Math.pow(v, 0.72));
|
|
const point = Math.pow(v, 3.2);
|
|
const ringRadius = radius * taper * (1 - point * 0.82) * (0.72 + v * 0.5);
|
|
const yPos = (v - 0.18) * height;
|
|
for (let x = 0; x <= radialSegments; x += 1) {
|
|
const u = x / radialSegments;
|
|
const angle = u * Math.PI * 2;
|
|
const lean = v * v * 0.018;
|
|
positions.push(
|
|
Math.cos(angle) * ringRadius + lean,
|
|
yPos,
|
|
Math.sin(angle) * ringRadius
|
|
);
|
|
normals.push(Math.cos(angle), 0.35, Math.sin(angle));
|
|
uvs.push(u, v);
|
|
}
|
|
}
|
|
|
|
for (let y = 0; y < heightSegments; y += 1) {
|
|
for (let x = 0; x < radialSegments; x += 1) {
|
|
const a = y * (radialSegments + 1) + x;
|
|
const b = a + 1;
|
|
const c = a + radialSegments + 1;
|
|
const d = c + 1;
|
|
indices.push(a, c, b, b, c, d);
|
|
}
|
|
}
|
|
|
|
const geometry = new THREE.BufferGeometry();
|
|
geometry.setIndex(indices);
|
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
|
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
|
|
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
|
|
geometry.computeVertexNormals();
|
|
return geometry;
|
|
}
|
|
|
|
function createFlameMaterial({ base, middle, tip, opacity, noiseScale, displacement }) {
|
|
return new THREE.ShaderMaterial({
|
|
transparent: true,
|
|
depthWrite: false,
|
|
depthTest: true,
|
|
blending: THREE.AdditiveBlending,
|
|
uniforms: {
|
|
time: { value: 0 },
|
|
baseColor: { value: base },
|
|
middleColor: { value: middle },
|
|
tipColor: { value: tip },
|
|
flameOpacity: { value: opacity },
|
|
noiseScale: { value: noiseScale },
|
|
displacement: { value: displacement }
|
|
},
|
|
vertexShader: `
|
|
uniform float time;
|
|
uniform float noiseScale;
|
|
uniform float displacement;
|
|
varying vec2 vUv;
|
|
varying float vHeight;
|
|
|
|
float wave(vec3 p) {
|
|
return sin(p.x * noiseScale + time * 7.1) * 0.5 +
|
|
sin((p.z + p.y) * noiseScale * 0.73 - time * 5.4) * 0.5;
|
|
}
|
|
|
|
void main() {
|
|
vUv = uv;
|
|
vHeight = uv.y;
|
|
vec3 transformed = position;
|
|
float flutter = wave(position) * displacement * smoothstep(0.08, 1.0, uv.y);
|
|
transformed.x += flutter;
|
|
transformed.z += sin(time * 4.9 + position.y * 23.0) * displacement * 0.35 * uv.y;
|
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0);
|
|
}
|
|
`,
|
|
fragmentShader: `
|
|
uniform float time;
|
|
uniform vec3 baseColor;
|
|
uniform vec3 middleColor;
|
|
uniform vec3 tipColor;
|
|
uniform float flameOpacity;
|
|
varying vec2 vUv;
|
|
varying float vHeight;
|
|
|
|
float hash(vec2 p) {
|
|
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
|
|
}
|
|
|
|
void main() {
|
|
float alphaShape = smoothstep(0.0, 0.18, vHeight) * smoothstep(1.0, 0.58, vHeight);
|
|
float flicker = 0.88 + sin(time * 9.0 + hash(vUv) * 6.2831) * 0.08;
|
|
vec3 lower = mix(baseColor, middleColor, smoothstep(0.08, 0.5, vHeight));
|
|
vec3 color = mix(lower, tipColor, smoothstep(0.54, 1.0, vHeight));
|
|
gl_FragColor = vec4(color * flicker, alphaShape * flameOpacity);
|
|
}
|
|
`
|
|
});
|
|
}
|
|
|
|
function createWaxMaterial(height) {
|
|
const material = new THREE.MeshPhysicalMaterial({
|
|
color: 0xffdfaa,
|
|
roughness: 0.52,
|
|
metalness: 0,
|
|
transmission: 0.46,
|
|
thickness: 0.42,
|
|
attenuationColor: 0xffb76a,
|
|
attenuationDistance: 0.62,
|
|
ior: 1.42,
|
|
emissive: 0xffb56a,
|
|
emissiveIntensity: 0.055,
|
|
envMapIntensity: 0
|
|
});
|
|
|
|
material.customProgramCacheKey = () => `book-lab-wax-flame-aware-sss-${height.toFixed(3)}`;
|
|
material.onBeforeCompile = (shader) => {
|
|
material.userData.shader = shader;
|
|
shader.uniforms.waxHeight = { value: height };
|
|
shader.uniforms.waxFlameWorldPosition = { value: new THREE.Vector3(0, height + 0.12, 0) };
|
|
shader.uniforms.waxBodyWorldPosition = { value: new THREE.Vector3() };
|
|
shader.uniforms.waxLightPower = { value: 1 };
|
|
shader.vertexShader = shader.vertexShader
|
|
.replace(
|
|
'#include <common>',
|
|
'#include <common>\nvarying float vWaxLocalY;\nvarying vec3 vWaxWorldPosition;\nvarying vec3 vWaxWorldNormal;'
|
|
)
|
|
.replace(
|
|
'#include <begin_vertex>',
|
|
'#include <begin_vertex>\nvWaxLocalY = position.y;'
|
|
)
|
|
.replace(
|
|
'#include <defaultnormal_vertex>',
|
|
'#include <defaultnormal_vertex>\nvWaxWorldNormal = normalize(mat3(modelMatrix) * objectNormal);'
|
|
)
|
|
.replace(
|
|
'#include <project_vertex>',
|
|
'vWaxWorldPosition = (modelMatrix * vec4(transformed, 1.0)).xyz;\n#include <project_vertex>'
|
|
);
|
|
shader.fragmentShader = shader.fragmentShader
|
|
.replace(
|
|
'#include <common>',
|
|
`#include <common>
|
|
uniform float waxHeight;
|
|
uniform vec3 waxFlameWorldPosition;
|
|
uniform vec3 waxBodyWorldPosition;
|
|
uniform float waxLightPower;
|
|
varying float vWaxLocalY;
|
|
varying vec3 vWaxWorldPosition;
|
|
varying vec3 vWaxWorldNormal;`
|
|
)
|
|
.replace(
|
|
'#include <opaque_fragment>',
|
|
`float waxTop = smoothstep(waxHeight * 0.08, waxHeight * 0.5, vWaxLocalY);
|
|
float waxRim = pow(1.0 - abs(dot(normalize(normal), normalize(geometryViewDir))), 2.2);
|
|
float waxCore = smoothstep(-waxHeight * 0.45, waxHeight * 0.3, vWaxLocalY);
|
|
float waxFlameCup = smoothstep(waxHeight * 0.28, waxHeight * 0.52, vWaxLocalY);
|
|
vec3 waxToFlame = waxFlameWorldPosition - vWaxWorldPosition;
|
|
float waxFlameDistance = length(waxToFlame);
|
|
vec3 waxLightDir = normalize(waxToFlame);
|
|
vec3 waxWorldNormal = normalize(vWaxWorldNormal);
|
|
float waxNearFlame = 1.0 - smoothstep(0.06, 0.58, waxFlameDistance);
|
|
float waxUpperBody = smoothstep(waxBodyWorldPosition.y + waxHeight * 0.38, waxBodyWorldPosition.y + waxHeight * 0.92, vWaxWorldPosition.y);
|
|
float waxForwardScatter = pow(max(dot(waxWorldNormal, waxLightDir), 0.0), 0.62);
|
|
float waxBackScatter = pow(max(dot(-waxWorldNormal, waxLightDir), 0.0), 1.5);
|
|
float waxSideGlow = pow(max(1.0 - abs(dot(waxWorldNormal, waxLightDir)), 0.0), 1.15);
|
|
float waxSubsurface = (waxNearFlame * (0.56 + waxForwardScatter * 0.42) + waxBackScatter * 0.25 + waxSideGlow * 0.18) * waxUpperBody * waxLightPower;
|
|
float waxCavityGlow = waxFlameCup * waxNearFlame * waxLightPower * 0.75;
|
|
vec3 waxScatter = vec3(1.0, 0.48, 0.19) * (waxTop * 0.11 + waxRim * waxCore * 0.09 + waxFlameCup * 0.08 + waxSubsurface * 0.46 + waxCavityGlow * 0.36);
|
|
outgoingLight += waxScatter;
|
|
#include <opaque_fragment>`
|
|
);
|
|
};
|
|
|
|
return material;
|
|
}
|
|
|
|
function configureTableShader(material) {
|
|
material.customProgramCacheKey = () => 'book-lab-table-planar-environment-reflection-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);
|
|
vec3 sceneReflection = texture2D(sceneReflectionMap, sceneReflectionUv).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 = 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 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 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 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();
|
|
updateTableReflection();
|
|
renderer.render(scene, camera);
|
|
}
|