From 67c0c4e7e35bec16110ce07b8c4e57a85df4f720 Mon Sep 17 00:00:00 2001 From: Georg Tomitsch Date: Sat, 6 Jun 2026 08:03:45 +0200 Subject: [PATCH] Add WebGL cloth and paper materials --- public/js/procedural-book-model.js | 23 ++- public/js/webgl-book-lab.js | 310 ++++++++++++++++++++++++++--- 2 files changed, 300 insertions(+), 33 deletions(-) diff --git a/public/js/procedural-book-model.js b/public/js/procedural-book-model.js index e6af518..a643687 100644 --- a/public/js/procedural-book-model.js +++ b/public/js/procedural-book-model.js @@ -208,6 +208,7 @@ function createCoverAssemblyGeometry(pageWidth, depth, thickness, spineWidth, co const uAt = (x) => (x - leftX) / (rightX - leftX || 1); const vAt = (z) => (z + halfDepth) / depth; const pointAt = (x, y, z) => ({ x, y, z, u: uAt(x), v: vAt(z) }); + const coverProfileXs = section.map((point) => point.x); const edgeProfile = Array.from({ length: edgeSteps + 1 }, (_, index) => { const angle = Math.PI * 0.5 - (index / edgeSteps) * Math.PI; return { @@ -221,14 +222,24 @@ function createCoverAssemblyGeometry(pageWidth, depth, thickness, spineWidth, co const cornerRadius = Math.max(0.0001, edgeRadius - inset); const points = []; const pushLinear = (fromX, fromZ, toX, toZ, steps) => { - for (let step = 0; step <= steps; step += 1) { - if (points.length && step === 0) continue; - const t = step / steps; - points.push({ - x: THREE.MathUtils.lerp(fromX, toX, t), - z: THREE.MathUtils.lerp(fromZ, toZ, t) + const candidates = []; + for (let step = 0; step <= steps; step += 1) candidates.push(step / steps); + if (Math.abs(fromZ - toZ) < 0.000001) { + coverProfileXs.forEach((x) => { + const t = (x - fromX) / (toX - fromX || 1); + if (t > 0 && t < 1) candidates.push(t); }); } + candidates + .sort((a, b) => a - b) + .forEach((t) => { + if (points.length && Math.abs(t) < 0.000001) return; + const x = THREE.MathUtils.lerp(fromX, toX, t); + const z = THREE.MathUtils.lerp(fromZ, toZ, t); + const previous = points[points.length - 1]; + if (previous && Math.hypot(previous.x - x, previous.z - z) < 0.000001) return; + points.push({ x, z }); + }); }; const pushCorner = (centerX, centerZ, fromAngle, toAngle) => { for (let step = 1; step <= cornerSteps; step += 1) { diff --git a/public/js/webgl-book-lab.js b/public/js/webgl-book-lab.js index e2ca1a8..9e2cbc0 100644 --- a/public/js/webgl-book-lab.js +++ b/public/js/webgl-book-lab.js @@ -169,6 +169,8 @@ const rightTexture = new THREE.CanvasTexture(rightCanvas); texture.generateMipmaps = true; }); const leatherTextures = createLeatherTextures(); +const spineClothTextures = createSpineClothTextures(); +const paperTextures = createHardcoverPaperTextures(); const materials = { leather: new THREE.MeshStandardMaterial({ @@ -216,55 +218,80 @@ const materials = { side: THREE.DoubleSide }), pageBlock: new THREE.MeshStandardMaterial({ - color: 0xe3c98f, - roughness: 0.82, + color: 0xfffbef, + map: paperTextures.color, + normalMap: paperTextures.normal, + normalScale: new THREE.Vector2(0.032, 0.032), + roughnessMap: paperTextures.roughness, + roughness: 0.88, metalness: 0, - envMapIntensity: 0.08 + envMapIntensity: 0.06 }), pageEdge: new THREE.MeshStandardMaterial({ - color: 0xc69f64, - roughness: 0.92, + color: 0xfff4cf, + map: paperTextures.edge, + normalMap: paperTextures.normal, + normalScale: new THREE.Vector2(0.024, 0.024), + roughnessMap: paperTextures.roughness, + roughness: 0.94, metalness: 0, - envMapIntensity: 0.08 + envMapIntensity: 0.05 }), pageSurface: new THREE.MeshStandardMaterial({ - color: 0xf0c17a, - roughness: 0.86, + color: 0xfffbf0, + map: paperTextures.color, + normalMap: paperTextures.normal, + normalScale: new THREE.Vector2(0.03, 0.03), + roughnessMap: paperTextures.roughness, + roughness: 0.9, metalness: 0, - emissive: 0x1e1209, - emissiveIntensity: 0.08, - envMapIntensity: 0.04, + emissive: 0x14110b, + emissiveIntensity: 0.025, + envMapIntensity: 0.035, side: THREE.DoubleSide }), leftPage: new THREE.MeshStandardMaterial({ color: 0xffffff, map: leftTexture, - roughness: 0.74, + normalMap: paperTextures.normal, + normalScale: new THREE.Vector2(0.025, 0.025), + roughnessMap: paperTextures.roughness, + roughness: 0.86, metalness: 0, - emissive: 0x2d1e12, - emissiveIntensity: 0.18, + emissive: 0x11100c, + emissiveIntensity: 0.035, side: THREE.DoubleSide }), rightPage: new THREE.MeshStandardMaterial({ color: 0xffffff, map: rightTexture, - roughness: 0.74, + normalMap: paperTextures.normal, + normalScale: new THREE.Vector2(0.025, 0.025), + roughnessMap: paperTextures.roughness, + roughness: 0.86, metalness: 0, - emissive: 0x2d1e12, - emissiveIntensity: 0.18, + emissive: 0x11100c, + emissiveIntensity: 0.035, side: THREE.DoubleSide }), spineCloth: new THREE.MeshStandardMaterial({ - color: 0x8e1d18, - normalMap: leatherTextures.normal, - normalScale: new THREE.Vector2(0.04, 0.04), - roughnessMap: leatherTextures.roughness, - roughness: 0.82, + color: 0x6f0808, + map: spineClothTextures.color, + normalMap: spineClothTextures.normal, + normalScale: new THREE.Vector2(0.075, 0.075), + roughnessMap: spineClothTextures.roughness, + roughness: 0.9, metalness: 0, - envMapIntensity: 0.08, + envMapIntensity: 0.045, side: THREE.DoubleSide }) }; +materials.spineCloth.userData.isSpineCloth = true; +configureHardcoverPaperMaterial(materials.pageBlock); +configureHardcoverPaperMaterial(materials.pageEdge, { useEdgeMap: true }); +configureHardcoverPaperMaterial(materials.pageSurface); +configureHardcoverPaperMaterial(materials.leftPage); +configureHardcoverPaperMaterial(materials.rightPage); configureBookShadowReceiver(materials.leather, 0.52); configureBookShadowReceiver(materials.hingeLeather, 0.36); @@ -393,7 +420,9 @@ function loadUtilityTexture(url) { } function configureBookShadowReceiver(material, strength) { - material.customProgramCacheKey = () => `book-shadow-receiver-${strength.toFixed(2)}`; + const isSpineCloth = material.userData?.isSpineCloth === true; + const isHardcoverPaper = material.userData?.isHardcoverPaper === true; + material.customProgramCacheKey = () => `book-shadow-receiver-${strength.toFixed(2)}-${isSpineCloth ? 'spine-cloth-v1' : isHardcoverPaper ? 'hardcover-paper-v1' : 'plain'}`; material.onBeforeCompile = (shader) => { shader.uniforms.bookShadowMaps = { value: bookShadowTargets.map((target) => target.texture) }; shader.uniforms.bookShadowMatrices = { value: bookShadowMatrices }; @@ -403,7 +432,14 @@ function configureBookShadowReceiver(material, strength) { shader.vertexShader = shader.vertexShader .replace( '#include ', - '#include \nvarying vec3 vBookReceiverWorldPosition;' + `#include + varying vec3 vBookReceiverWorldPosition; + ${isSpineCloth || isHardcoverPaper ? 'varying vec2 vBookSurfaceUv;' : ''}` + ) + .replace( + '#include ', + `${isSpineCloth || isHardcoverPaper ? 'vBookSurfaceUv = uv;' : ''} + #include ` ) .replace( '#include ', @@ -419,6 +455,7 @@ function configureBookShadowReceiver(material, strength) { uniform vec2 bookShadowMapTexelSize; uniform float bookShadowReceiverStrength; varying vec3 vBookReceiverWorldPosition; + ${isSpineCloth || isHardcoverPaper ? 'varying vec2 vBookSurfaceUv;' : ''} float bookReceiverUnpackRGBADepth(vec4 packedDepth) { const vec4 unpackFactors = vec4( @@ -485,11 +522,45 @@ function configureBookShadowReceiver(material, strength) { float shadow1 = bookReceiverSample1(bookShadowMatrices[1] * vec4(worldPosition, 1.0)); float shadow2 = bookReceiverSample2(bookShadowMatrices[2] * vec4(worldPosition, 1.0)); return clamp(max(max(shadow0, shadow1), shadow2), 0.0, 1.0); + } + + float spineClothThread(float coordinate, float frequency, float sharpness) { + float wave = abs(fract(coordinate * frequency) - 0.5) * 2.0; + return pow(1.0 - wave, sharpness); + } + + vec3 spineClothLight(vec2 uv, vec3 baseLight) { + float warp = spineClothThread(uv.x + sin(uv.y * 18.0) * 0.002, 92.0, 2.4); + float weft = spineClothThread(uv.y + sin(uv.x * 21.0) * 0.0016, 64.0, 2.1); + float fineFiber = sin((uv.x * 420.0 + uv.y * 55.0) * 6.28318530718) * + sin((uv.y * 380.0 - uv.x * 33.0) * 6.28318530718); + float raisedThread = clamp(warp * 0.58 + weft * 0.44, 0.0, 1.0); + float valley = clamp((1.0 - warp) * (1.0 - weft), 0.0, 1.0); + vec3 threadTint = mix(vec3(0.55, 0.19, 0.16), vec3(1.18, 0.78, 0.58), raisedThread); + float fiberShade = 0.9 + fineFiber * 0.035 - valley * 0.18; + return baseLight * threadTint * fiberShade; + } + + float paperFiber(float coordinate, float frequency, float sharpness) { + float wave = abs(fract(coordinate * frequency) - 0.5) * 2.0; + return pow(1.0 - wave, sharpness); + } + + vec3 hardcoverPaperLight(vec2 uv, vec3 baseLight) { + float laid = paperFiber(uv.y + sin(uv.x * 12.0) * 0.002, 88.0, 2.8); + float chain = paperFiber(uv.x + sin(uv.y * 8.0) * 0.0015, 18.0, 1.6); + float fleck = sin((uv.x * 241.0 + uv.y * 97.0) * 6.28318530718) * + sin((uv.y * 211.0 - uv.x * 53.0) * 6.28318530718); + float fiber = clamp(laid * 0.18 + chain * 0.1 + fleck * 0.025, -0.08, 0.24); + vec3 paperTint = mix(vec3(0.92, 0.9, 0.82), vec3(1.12, 1.08, 0.96), clamp(0.58 + fiber, 0.0, 1.0)); + return baseLight * paperTint; }` ) .replace( '#include ', - `float bookReceiverShadow = bookReceiverShadowField(vBookReceiverWorldPosition) * bookShadowReceiverStrength; + `${isSpineCloth ? 'outgoingLight = spineClothLight(vBookSurfaceUv, outgoingLight);' : ''} + ${isHardcoverPaper ? 'outgoingLight = hardcoverPaperLight(vBookSurfaceUv, outgoingLight);' : ''} + float bookReceiverShadow = bookReceiverShadowField(vBookReceiverWorldPosition) * bookShadowReceiverStrength; outgoingLight *= mix(vec3(1.0), vec3(0.38, 0.29, 0.2), bookReceiverShadow); #include ` ); @@ -1190,6 +1261,9 @@ function buildBook() { rightPage: materials.rightPage }, configureMaterial(material, part) { + if (part === 'pages') { + configureHardcoverPaperMaterial(material, { useEdgeMap: material.map !== null }); + } const strength = part === 'spine' ? 0.48 : part === 'coverSpineBase' @@ -1208,6 +1282,18 @@ function buildBook() { book.add(proceduralBook.group); } +function configureHardcoverPaperMaterial(material, { useEdgeMap = false } = {}) { + material.userData.isHardcoverPaper = true; + if (!material.map) material.map = useEdgeMap ? paperTextures.edge : paperTextures.color; + material.normalMap = paperTextures.normal; + material.normalScale = material.normalScale ?? new THREE.Vector2(0.024, 0.024); + material.roughnessMap = paperTextures.roughness; + material.roughness = Math.max(material.roughness ?? 0.86, useEdgeMap ? 0.92 : 0.86); + material.metalness = 0; + material.envMapIntensity = Math.min(material.envMapIntensity ?? 0.05, 0.06); + material.needsUpdate = true; +} + function setReadingProgress(value) { const nextProgress = THREE.MathUtils.clamp(Number.parseFloat(value), 0, 1); if (!Number.isFinite(nextProgress)) return; @@ -1797,7 +1883,7 @@ function createPageCanvas(side) { canvas.width = pageTextureWidth; canvas.height = Math.round(pageTextureWidth * PROCEDURAL_BOOK.PAGE_DEPTH / PROCEDURAL_BOOK.PAGE_WIDTH); const ctx = canvas.getContext('2d'); - ctx.fillStyle = '#f5dfab'; + ctx.fillStyle = '#fffaf0'; ctx.fillRect(0, 0, canvas.width, canvas.height); canvas.style.width = `${canvas.width}px`; canvas.style.height = `${canvas.height}px`; @@ -1928,6 +2014,176 @@ function createLeatherTextures() { return { color: colorTexture, normal: normalTexture, roughness: roughnessTexture }; } +function createSpineClothTextures() { + const size = 1024; + const colorCanvas = document.createElement('canvas'); + const normalCanvas = document.createElement('canvas'); + const roughnessCanvas = document.createElement('canvas'); + colorCanvas.width = size; + colorCanvas.height = size; + normalCanvas.width = size; + normalCanvas.height = size; + roughnessCanvas.width = size; + roughnessCanvas.height = size; + const colorContext = colorCanvas.getContext('2d'); + const normalContext = normalCanvas.getContext('2d'); + const roughnessContext = roughnessCanvas.getContext('2d'); + const colorImage = colorContext.createImageData(size, size); + const normalImage = normalContext.createImageData(size, size); + const roughnessImage = roughnessContext.createImageData(size, size); + const threadAt = (x, y) => { + const nx = x / size; + const ny = y / size; + const warpPhase = nx * 112 + Math.sin(ny * 31.4159265359) * 0.025; + const weftPhase = ny * 76 + Math.sin(nx * 25.1327412287) * 0.02; + const warp = Math.pow(1 - Math.abs((warpPhase - Math.floor(warpPhase)) - 0.5) * 2, 2.2); + const weft = Math.pow(1 - Math.abs((weftPhase - Math.floor(weftPhase)) - 0.5) * 2, 2.0); + const fiber = Math.sin((nx * 430 + ny * 73) * 6.28318530718) * Math.sin((ny * 390 - nx * 41) * 6.28318530718); + const nap = Math.sin((nx * 19 + ny * 7) * 6.28318530718); + return warp * 0.46 + weft * 0.38 + fiber * 0.045 + nap * 0.055; + }; + + for (let y = 0; y < size; y += 1) { + for (let x = 0; x < size; x += 1) { + const index = (y * size + x) * 4; + const height = threadAt(x, y); + const wornFiber = 0.86 + 0.1 * Math.sin((x * 0.019 + y * 0.037)) + 0.04 * Math.sin((x * 0.083 - y * 0.011)); + const threadGlow = THREE.MathUtils.clamp(0.58 + height * 0.46, 0, 1); + colorImage.data[index] = Math.round(95 * threadGlow * wornFiber); + colorImage.data[index + 1] = Math.round(10 * threadGlow * wornFiber); + colorImage.data[index + 2] = Math.round(9 * (0.84 + height * 0.12)); + colorImage.data[index + 3] = 255; + + const hLeft = threadAt((x - 1 + size) % size, y); + const hRight = threadAt((x + 1) % size, y); + const hDown = threadAt(x, (y - 1 + size) % size); + const hUp = threadAt(x, (y + 1) % size); + const normal = new THREE.Vector3((hLeft - hRight) * 5.4, (hDown - hUp) * 5.4, 1).normalize(); + normalImage.data[index] = Math.round((normal.x * 0.5 + 0.5) * 255); + normalImage.data[index + 1] = Math.round((normal.y * 0.5 + 0.5) * 255); + normalImage.data[index + 2] = Math.round((normal.z * 0.5 + 0.5) * 255); + normalImage.data[index + 3] = 255; + + const fiberContrast = Math.abs(hLeft - hRight) + Math.abs(hDown - hUp); + const roughness = THREE.MathUtils.clamp(0.84 + height * 0.07 + fiberContrast * 1.25, 0.62, 0.98); + const roughnessByte = Math.round(roughness * 255); + roughnessImage.data[index] = roughnessByte; + roughnessImage.data[index + 1] = roughnessByte; + roughnessImage.data[index + 2] = roughnessByte; + roughnessImage.data[index + 3] = 255; + } + } + + colorContext.putImageData(colorImage, 0, 0); + normalContext.putImageData(normalImage, 0, 0); + roughnessContext.putImageData(roughnessImage, 0, 0); + const colorTexture = new THREE.CanvasTexture(colorCanvas); + const normalTexture = new THREE.CanvasTexture(normalCanvas); + const roughnessTexture = new THREE.CanvasTexture(roughnessCanvas); + [colorTexture, normalTexture, roughnessTexture].forEach((texture) => { + texture.wrapS = THREE.RepeatWrapping; + texture.wrapT = THREE.RepeatWrapping; + texture.repeat.set(2.1, 4.4); + texture.anisotropy = maxTextureAnisotropy; + texture.minFilter = THREE.LinearMipmapLinearFilter; + texture.magFilter = THREE.LinearFilter; + texture.generateMipmaps = true; + }); + colorTexture.colorSpace = THREE.SRGBColorSpace; + normalTexture.colorSpace = THREE.NoColorSpace; + roughnessTexture.colorSpace = THREE.NoColorSpace; + return { color: colorTexture, normal: normalTexture, roughness: roughnessTexture }; +} + +function createHardcoverPaperTextures() { + const size = 1024; + const colorCanvas = document.createElement('canvas'); + const edgeCanvas = document.createElement('canvas'); + const normalCanvas = document.createElement('canvas'); + const roughnessCanvas = document.createElement('canvas'); + [colorCanvas, edgeCanvas, normalCanvas, roughnessCanvas].forEach((canvas) => { + canvas.width = size; + canvas.height = size; + }); + const colorContext = colorCanvas.getContext('2d'); + const edgeContext = edgeCanvas.getContext('2d'); + const normalContext = normalCanvas.getContext('2d'); + const roughnessContext = roughnessCanvas.getContext('2d'); + const colorImage = colorContext.createImageData(size, size); + const edgeImage = edgeContext.createImageData(size, size); + const normalImage = normalContext.createImageData(size, size); + const roughnessImage = roughnessContext.createImageData(size, size); + const fiberAt = (x, y) => { + const nx = x / size; + const ny = y / size; + const laid = Math.sin((ny * 92 + Math.sin(nx * 25.1327412287) * 0.12) * 6.28318530718); + const chain = Math.sin((nx * 18 + Math.sin(ny * 12.5663706144) * 0.06) * 6.28318530718); + const pulpA = Math.sin((nx * 173 + ny * 67) * 6.28318530718); + const pulpB = Math.sin((nx * 89 - ny * 131) * 6.28318530718); + const fleck = Math.max(0, 0.5 - Math.abs(pulpA * pulpB)); + return laid * 0.08 + chain * 0.045 - fleck * 0.055; + }; + + for (let y = 0; y < size; y += 1) { + for (let x = 0; x < size; x += 1) { + const index = (y * size + x) * 4; + const fiber = fiberAt(x, y); + const warmth = 0.97 + 0.018 * Math.sin(x * 0.017 + y * 0.003) + 0.012 * Math.sin(y * 0.041); + const shade = THREE.MathUtils.clamp(0.975 + fiber, 0.88, 1.0); + colorImage.data[index] = Math.round(255 * shade * warmth); + colorImage.data[index + 1] = Math.round(251 * shade * warmth); + colorImage.data[index + 2] = Math.round(235 * shade); + colorImage.data[index + 3] = 255; + + const line = y % 34 === 0 ? 0.72 : y % 34 === 1 ? 0.82 : 1; + edgeImage.data[index] = Math.round(255 * shade * line); + edgeImage.data[index + 1] = Math.round(244 * shade * line); + edgeImage.data[index + 2] = Math.round(207 * shade * line); + edgeImage.data[index + 3] = 255; + + const hLeft = fiberAt((x - 1 + size) % size, y); + const hRight = fiberAt((x + 1) % size, y); + const hDown = fiberAt(x, (y - 1 + size) % size); + const hUp = fiberAt(x, (y + 1) % size); + const normal = new THREE.Vector3((hLeft - hRight) * 3.2, (hDown - hUp) * 3.2, 1).normalize(); + normalImage.data[index] = Math.round((normal.x * 0.5 + 0.5) * 255); + normalImage.data[index + 1] = Math.round((normal.y * 0.5 + 0.5) * 255); + normalImage.data[index + 2] = Math.round((normal.z * 0.5 + 0.5) * 255); + normalImage.data[index + 3] = 255; + + const roughness = THREE.MathUtils.clamp(0.86 + Math.abs(fiber) * 0.5 + Math.abs(hLeft - hRight + hDown - hUp) * 1.2, 0.72, 0.98); + const roughnessByte = Math.round(roughness * 255); + roughnessImage.data[index] = roughnessByte; + roughnessImage.data[index + 1] = roughnessByte; + roughnessImage.data[index + 2] = roughnessByte; + roughnessImage.data[index + 3] = 255; + } + } + + colorContext.putImageData(colorImage, 0, 0); + edgeContext.putImageData(edgeImage, 0, 0); + normalContext.putImageData(normalImage, 0, 0); + roughnessContext.putImageData(roughnessImage, 0, 0); + const colorTexture = new THREE.CanvasTexture(colorCanvas); + const edgeTexture = new THREE.CanvasTexture(edgeCanvas); + const normalTexture = new THREE.CanvasTexture(normalCanvas); + const roughnessTexture = new THREE.CanvasTexture(roughnessCanvas); + [colorTexture, edgeTexture, normalTexture, roughnessTexture].forEach((texture) => { + texture.wrapS = THREE.RepeatWrapping; + texture.wrapT = THREE.RepeatWrapping; + texture.repeat.set(2.6, 3.4); + texture.anisotropy = maxTextureAnisotropy; + texture.minFilter = THREE.LinearMipmapLinearFilter; + texture.magFilter = THREE.LinearFilter; + texture.generateMipmaps = true; + }); + colorTexture.colorSpace = THREE.SRGBColorSpace; + edgeTexture.colorSpace = THREE.SRGBColorSpace; + normalTexture.colorSpace = THREE.NoColorSpace; + roughnessTexture.colorSpace = THREE.NoColorSpace; + return { color: colorTexture, edge: edgeTexture, normal: normalTexture, roughness: roughnessTexture }; +} + function createRoomReflectionTexture() { const canvas = document.createElement('canvas'); generatedTextureCanvases.roomReflection = canvas;