From 444acb62296842713f3c655192b91f89925d3aff Mon Sep 17 00:00:00 2001 From: Georg Tomitsch Date: Thu, 4 Jun 2026 13:37:41 +0200 Subject: [PATCH] Refine WebGL table surface contamination --- public/js/webgl-book-lab.js | 160 ++++++++++++++++++++++++++++++------ 1 file changed, 134 insertions(+), 26 deletions(-) diff --git a/public/js/webgl-book-lab.js b/public/js/webgl-book-lab.js index 54fe6a1..5838809 100644 --- a/public/js/webgl-book-lab.js +++ b/public/js/webgl-book-lab.js @@ -10,7 +10,8 @@ const tableDebugModes = { room: 4, scene: 5, mask: 6, - ao: 7 + ao: 7, + grease: 8 }; const urlParams = new URLSearchParams(window.location.search); const tableDebugName = urlParams.get('tableDebug') || 'none'; @@ -40,6 +41,7 @@ let tableMesh = null; let tableShader = null; let tableRoomReflectionTexture = createRoomReflectionTexture(); let tableDustTexture = null; +let tableGreaseTexture = null; const tableTopY = -0.02; const tableReflectionTarget = new THREE.WebGLRenderTarget(4096, 2304, { colorSpace: THREE.SRGBColorSpace, @@ -183,17 +185,21 @@ function buildTable() { tableDustTexture.wrapS = THREE.ClampToEdgeWrapping; tableDustTexture.wrapT = THREE.ClampToEdgeWrapping; tableDustTexture.anisotropy = renderer.capabilities.getMaxAnisotropy(); + tableGreaseTexture = createTableGreaseTexture(); + tableGreaseTexture.wrapS = THREE.ClampToEdgeWrapping; + tableGreaseTexture.wrapT = THREE.ClampToEdgeWrapping; + tableGreaseTexture.anisotropy = renderer.capabilities.getMaxAnisotropy(); const tableMaterial = new THREE.MeshPhysicalMaterial({ color: 0x8a4c22, map: tableTexture, normalMap: tableNormal, normalScale: new THREE.Vector2(0.22, 0.18), - roughness: 0.38, + roughness: 0.42, metalness: 0, - clearcoat: 0.54, - clearcoatRoughness: 0.48, - reflectivity: 0.34, + clearcoat: 0.32, + clearcoatRoughness: 0.58, + reflectivity: 0.18, envMapIntensity: 0 }); configureTableShader(tableMaterial); @@ -510,6 +516,7 @@ function configureTableShader(material) { shader.uniforms.sceneReflectionMap = { value: tableReflectionTarget.texture }; shader.uniforms.sceneReflectionMatrix = { value: tableReflectionMatrix }; shader.uniforms.tableDustMap = { value: tableDustTexture }; + shader.uniforms.tableGreaseMap = { value: tableGreaseTexture }; shader.uniforms.candleBodyPositions = { value: [new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3()] }; @@ -538,6 +545,7 @@ function configureTableShader(material) { uniform sampler2D roomReflectionMap; uniform sampler2D sceneReflectionMap; uniform sampler2D tableDustMap; + uniform sampler2D tableGreaseMap; uniform mat4 sceneReflectionMatrix; uniform vec3 candleBodyPositions[3]; uniform vec3 candleFlamePositions[3]; @@ -546,6 +554,22 @@ function configureTableShader(material) { varying vec3 vTableWorldPosition; varying vec4 vSceneReflectionCoord; + vec2 tableDustUvFromWorld(vec3 worldPosition) { + return clamp(vec2(worldPosition.x / 9.8 + 0.5, 0.5 - worldPosition.z / 6.6), vec2(0.0), vec2(1.0)); + } + + float tableTopMaskFromWorld(vec3 worldPosition) { + return smoothstep(-0.095, -0.025, worldPosition.y); + } + + float tableDustFromWorld(vec3 worldPosition) { + return texture2D(tableDustMap, tableDustUvFromWorld(worldPosition)).r * tableTopMaskFromWorld(worldPosition); + } + + float tableGreaseFromWorld(vec3 worldPosition) { + return texture2D(tableGreaseMap, tableDustUvFromWorld(worldPosition)).r * tableTopMaskFromWorld(worldPosition); + } + vec3 rotateRoomReflection(vec3 dir) { const float yaw = 0.42; float s = sin(yaw); @@ -635,6 +659,16 @@ function configureTableShader(material) { return clamp(projectedShadow, 0.0, 0.46); }` ) + .replace( + '#include ', + `#include + float tableSpecularGrease = tableGreaseFromWorld(vTableWorldPosition); + float tableSpecularDust = tableDustFromWorld(vTableWorldPosition) * (1.0 - smoothstep(0.025, 0.18, tableSpecularGrease) * 0.82); + float tableSpecularDustFilm = smoothstep(0.006, 0.034, tableSpecularDust); + float tableSpecularGreaseFilm = smoothstep(0.016, 0.14, tableSpecularGrease); + roughnessFactor = clamp(mix(roughnessFactor, 0.84, tableSpecularDustFilm * 0.32), 0.04, 1.0); + roughnessFactor = clamp(mix(roughnessFactor, 0.34, tableSpecularGreaseFilm * 0.3), 0.04, 1.0);` + ) .replace( '#include ', `vec3 viewDirWorld = normalize(cameraPosition - vTableWorldPosition); @@ -652,23 +686,32 @@ function configureTableShader(material) { 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 grease = tableGreaseFromWorld(vTableWorldPosition); + float greaseDustWipe = smoothstep(0.016, 0.13, grease); + float dust = tableDustFromWorld(vTableWorldPosition) * (1.0 - greaseDustWipe * 0.82); + float dustFilm = smoothstep(0.006, 0.034, dust); + float greaseFilm = smoothstep(0.016, 0.14, grease); + float reflectionCleanliness = 1.0 - dustFilm * 0.2 - greaseFilm * 0.06; + vec3 dustBlurredSceneReflection = sceneReflection * 0.78 + roomReflection * 0.055; + vec3 greaseBlurredSceneReflection = sceneReflection * 0.7 + roomReflection * 0.18; + vec3 dustAwareSceneReflection = mix(sceneReflection, dustBlurredSceneReflection, dustFilm); + dustAwareSceneReflection = mix(dustAwareSceneReflection, greaseBlurredSceneReflection, greaseFilm); + vec3 combinedReflection = (roomReflection * 0.14 + dustAwareSceneReflection * 0.86) * reflectionCleanliness; float fresnel = pow(1.0 - max(dot(viewDirWorld, tableNormalWorld), 0.0), 1.85); float tableReflectionMask = smoothstep(-0.095, -0.025, vTableWorldPosition.y); - vec3 reflectedSurface = combinedReflection * (0.56 + fresnel * 0.44); + vec3 reflectedSurface = combinedReflection * (0.64 + fresnel * 0.36); + float reflectedLuma = dot(reflectedSurface, vec3(0.299, 0.587, 0.114)); + reflectedSurface = mix(reflectedSurface, vec3(reflectedLuma), dustFilm * 0.42); float 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(outgoingLight, reflectedSurface, tableReflectionMask * (0.16 + fresnel * 0.28 + greaseFilm * 0.065) * reflectionCleanliness); + outgoingLight += tableReflectionMask * roomReflection * 0.004 * reflectionCleanliness; + outgoingLight += tableReflectionMask * dustFilm * vec3(0.008, 0.0085, 0.009) * (0.22 + fresnel * 0.62); + outgoingLight += tableReflectionMask * greaseFilm * vec3(0.018, 0.013, 0.007) * (0.28 + fresnel * 0.58); outgoingLight *= mix(vec3(1.0), vec3(0.5, 0.4, 0.31), candleOcclusion); if (tableDebugMode == 1) outgoingLight = vec3(candleProjectedShadow); if (tableDebugMode == 2) outgoingLight = vec3(dust); @@ -677,6 +720,7 @@ function configureTableShader(material) { if (tableDebugMode == 5) outgoingLight = sceneReflection; if (tableDebugMode == 6) outgoingLight = vec3(tableReflectionMask); if (tableDebugMode == 7) outgoingLight = vec3(candleContact); + if (tableDebugMode == 8) outgoingLight = vec3(grease); #include ` ); }; @@ -965,10 +1009,10 @@ function createTableNormalTexture() { function createTableDustTexture() { const canvas = document.createElement('canvas'); generatedTextureCanvases.tableDust = canvas; - canvas.width = 2048; - canvas.height = 2048; + canvas.width = 4096; + canvas.height = 4096; const ctx = canvas.getContext('2d'); - ctx.fillStyle = 'rgb(10, 10, 10)'; + ctx.fillStyle = 'rgb(1, 1, 1)'; ctx.fillRect(0, 0, canvas.width, canvas.height); const image = ctx.getImageData(0, 0, canvas.width, canvas.height); @@ -977,11 +1021,11 @@ function createTableDustTexture() { 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); + const edgeDust = Math.max(0, 1 - Math.min(nx, ny, 1 - nx, 1 - ny) * 18); + const microNoise = Math.pow(Math.random(), 7.2) * 5.5; + const fineFilm = Math.max(0, Math.sin(nx * 21 + Math.sin(ny * 11) * 0.7) - 0.988) * 3; + const bookShelter = Math.exp(-Math.pow((nx - 0.5) * 2.7, 2) - Math.pow((ny - 0.5) * 1.8, 2)) * 1.5; + const value = Math.min(255, 1 + edgeDust * 3 + microNoise + fineFilm + bookShelter); image.data[i] = value; image.data[i + 1] = value; image.data[i + 2] = value; @@ -990,6 +1034,36 @@ function createTableDustTexture() { } ctx.putImageData(image, 0, 0); + ctx.globalCompositeOperation = 'screen'; + ctx.fillStyle = 'rgba(230, 230, 230, 0.028)'; + for (let i = 0; i < 1700; i += 1) { + const x = Math.random() * canvas.width; + const y = Math.random() * canvas.height; + const radius = 0.08 + Math.random() * 0.22; + ctx.beginPath(); + ctx.arc(x, y, radius, 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalCompositeOperation = 'source-over'; + + 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 createTableGreaseTexture() { + const canvas = document.createElement('canvas'); + generatedTextureCanvases.tableGrease = canvas; + canvas.width = 4096; + canvas.height = 4096; + const ctx = canvas.getContext('2d'); + ctx.fillStyle = 'rgb(0, 0, 0)'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + 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})`); @@ -1005,10 +1079,44 @@ function createTableDustTexture() { 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); + smudge(canvas.width * 0.17, canvas.height * 0.38, 210, 88, 0.088); + smudge(canvas.width * 0.83, canvas.height * 0.24, 170, 76, 0.081); + smudge(canvas.width * 0.73, canvas.height * 0.76, 185, 78, 0.072); + smudge(canvas.width * 0.5, canvas.height * 0.52, 300, 120, 0.053); + + const fingerprint = (x, y, rx, ry, rotation, alpha) => { + ctx.save(); + ctx.translate(x, y); + ctx.rotate(rotation); + const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, Math.max(rx, ry)); + gradient.addColorStop(0, `rgba(235, 235, 235, ${alpha})`); + gradient.addColorStop(0.68, `rgba(200, 200, 200, ${alpha * 0.48})`); + gradient.addColorStop(1, 'rgba(0, 0, 0, 0)'); + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.ellipse(0, 0, rx, ry, 0, 0, Math.PI * 2); + ctx.fill(); + + ctx.beginPath(); + ctx.ellipse(0, 0, rx, ry, 0, 0, Math.PI * 2); + ctx.clip(); + ctx.strokeStyle = `rgba(245, 245, 245, ${alpha * 0.42})`; + ctx.lineWidth = 2.4; + for (let ridge = -0.7; ridge <= 0.7; ridge += 0.18) { + ctx.beginPath(); + ctx.ellipse(rx * ridge * 0.18, ry * ridge * 0.24, rx * (0.26 + Math.abs(ridge) * 0.58), ry * (0.2 + Math.abs(ridge) * 0.5), 0, Math.PI * 0.08, Math.PI * 1.92); + ctx.stroke(); + } + ctx.restore(); + }; + + ctx.globalCompositeOperation = 'screen'; + for (let i = 0; i < 8; i += 1) { + const x = canvas.width * (0.2 + Math.random() * 0.62); + const y = canvas.height * (0.18 + Math.random() * 0.64); + fingerprint(x, y, 36 + Math.random() * 22, 14 + Math.random() * 8, Math.random() * Math.PI, 0.102 + Math.random() * 0.042); + } + ctx.globalCompositeOperation = 'source-over'; const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.NoColorSpace;