diff --git a/public/js/procedural-book-model.js b/public/js/procedural-book-model.js index cb1cc42..aaa6235 100644 --- a/public/js/procedural-book-model.js +++ b/public/js/procedural-book-model.js @@ -68,6 +68,12 @@ function createBookContext(options) { envMapIntensity: 0.08, side: THREE.DoubleSide }), + headband: options.materials?.headband ?? new THREE.MeshStandardMaterial({ + color: 0xf4dcc0, + roughness: 0.82, + metalness: 0, + envMapIntensity: 0.06 + }), pageTop: options.materials?.pageTop ?? new THREE.MeshStandardMaterial({ color: 0xf1dfba, roughness: 0.82, @@ -324,26 +330,53 @@ function addClothSpine(group, context, model) { mesh.userData.bookPart = 'spine'; configurePartMaterial(context, mesh.material, 'spine'); group.add(mesh); + + createHeadbandMeshes(context, model).forEach((headband) => group.add(headband)); +} + +function createHeadbandMeshes(context, model) { + const radius = 0.0046; + const centerOffset = radius * 0.62; + const spineProfile = []; + for (let i = 2; i <= 30; i += 1) { + const point = spineCurvePoint(i / 32, model.spineWidth); + spineProfile.push(new THREE.Vector3(point.x, point.y + 0.0012, 0)); + } + const meshes = []; + [-1, 1].forEach((zSide) => { + const z = zSide * (model.pageDepth * 0.5 + centerOffset); + const curve = new THREE.CatmullRomCurve3(spineProfile.map((point) => point.clone().setZ(z))); + const geometry = new THREE.TubeGeometry(curve, 56, radius, 10, false); + const mesh = new THREE.Mesh(geometry, context.materials.headband); + mesh.userData.bookPart = 'headband'; + configurePartMaterial(context, mesh.material, 'headband'); + meshes.push(mesh); + }); + return meshes; } function createClothSpineGeometry(depth, spineWidth) { + const endOverrun = 0.0012; const profile = []; for (let i = 0; i <= 32; i += 1) { profile.push(spineCurvePoint(i / 32, spineWidth)); } const positions = []; + const uvs = []; const indices = []; const front = []; const back = []; - const push = (point, z) => { + const push = (point, z, uv) => { const index = positions.length / 3; positions.push(point.x, point.y, z); + uvs.push(uv.u, uv.v); return index; }; - profile.forEach((point) => { - front.push(push(point, depth * 0.5 + 0.024)); - back.push(push(point, -depth * 0.5 - 0.024)); + profile.forEach((point, index) => { + const u = profile.length <= 1 ? 0.5 : index / (profile.length - 1); + front.push(push(point, depth * 0.5 + endOverrun, { u, v: 1 })); + back.push(push(point, -depth * 0.5 - endOverrun, { u, v: 0 })); }); for (let i = 0; i < profile.length - 1; i += 1) { indices.push(front[i], back[i], front[i + 1]); @@ -353,6 +386,7 @@ function createClothSpineGeometry(depth, spineWidth) { 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; } diff --git a/public/js/webgl-book-lab.js b/public/js/webgl-book-lab.js index 475a5df..bc29491 100644 --- a/public/js/webgl-book-lab.js +++ b/public/js/webgl-book-lab.js @@ -170,6 +170,7 @@ const rightTexture = new THREE.CanvasTexture(rightCanvas); }); const leatherTextures = createLeatherTextures(); const spineClothTextures = createSpineClothTextures(); +const headbandTextures = createHeadbandTextures(); const paperTextures = createHardcoverPaperTextures(); const materials = { @@ -284,9 +285,20 @@ const materials = { metalness: 0, envMapIntensity: 0.075, side: THREE.DoubleSide + }), + headband: new THREE.MeshStandardMaterial({ + color: 0xffffff, + map: headbandTextures.color, + normalMap: headbandTextures.normal, + normalScale: new THREE.Vector2(0.055, 0.055), + roughnessMap: headbandTextures.roughness, + roughness: 0.96, + metalness: 0, + envMapIntensity: 0 }) }; materials.spineCloth.userData.isSpineCloth = true; +materials.headband.userData.isHeadband = true; configureHardcoverPaperMaterial(materials.pageBlock); configureHardcoverPaperMaterial(materials.pageEdge, { useEdgeMap: true }); configureHardcoverPaperMaterial(materials.pageSurface); @@ -303,6 +315,7 @@ configureBookShadowReceiver(materials.pageSurface, 0.34); configureBookShadowReceiver(materials.leftPage, 0.38); configureBookShadowReceiver(materials.rightPage, 0.38); configureBookShadowReceiver(materials.spineCloth, 0.48); +configureBookShadowReceiver(materials.headband, 0.62); buildTable(); buildLighting(); @@ -422,23 +435,31 @@ function loadUtilityTexture(url) { function configureBookShadowReceiver(material, strength) { 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'}`; + const isHeadband = material.userData?.isHeadband === true; + material.customProgramCacheKey = () => `book-shadow-receiver-${strength.toFixed(2)}-${isHeadband ? 'headband-v1' : isSpineCloth ? 'spine-cloth-v4' : isHardcoverPaper ? 'hardcover-paper-v1' : 'plain'}`; material.onBeforeCompile = (shader) => { shader.uniforms.bookShadowMaps = { value: bookShadowTargets.map((target) => target.texture) }; shader.uniforms.bookShadowMatrices = { value: bookShadowMatrices }; shader.uniforms.bookShadowMapTexelSize = { value: new THREE.Vector2(1 / bookShadowMapSize, 1 / bookShadowMapSize) }; shader.uniforms.bookShadowReceiverStrength = { value: strength }; + shader.uniforms.bookTableTopY = { value: tableTopY }; shader.vertexShader = shader.vertexShader .replace( '#include ', `#include varying vec3 vBookReceiverWorldPosition; - ${isSpineCloth || isHardcoverPaper ? 'varying vec2 vBookSurfaceUv;' : ''}` + varying vec3 vBookReceiverWorldNormal; + ${isSpineCloth || isHardcoverPaper || isHeadband ? 'varying vec2 vBookSurfaceUv;' : ''}` + ) + .replace( + '#include ', + `#include + vBookReceiverWorldNormal = normalize(mat3(modelMatrix) * objectNormal);` ) .replace( '#include ', - `${isSpineCloth || isHardcoverPaper ? 'vBookSurfaceUv = uv;' : ''} + `${isSpineCloth || isHardcoverPaper || isHeadband ? 'vBookSurfaceUv = uv;' : ''} #include ` ) .replace( @@ -454,8 +475,10 @@ function configureBookShadowReceiver(material, strength) { uniform mat4 bookShadowMatrices[3]; uniform vec2 bookShadowMapTexelSize; uniform float bookShadowReceiverStrength; + uniform float bookTableTopY; varying vec3 vBookReceiverWorldPosition; - ${isSpineCloth || isHardcoverPaper ? 'varying vec2 vBookSurfaceUv;' : ''} + varying vec3 vBookReceiverWorldNormal; + ${isSpineCloth || isHardcoverPaper || isHeadband ? 'varying vec2 vBookSurfaceUv;' : ''} float bookReceiverUnpackRGBADepth(vec4 packedDepth) { const vec4 unpackFactors = vec4( @@ -524,6 +547,19 @@ function configureBookShadowReceiver(material, strength) { return clamp(max(max(shadow0, shadow1), shadow2), 0.0, 1.0); } + vec3 bookLocalBounce(vec3 worldPosition, vec3 worldNormal, float shadow) { + float tableDistance = max(0.0, worldPosition.y - bookTableTopY); + float tableReach = 1.0 - smoothstep(0.02, 0.24, tableDistance); + float grazingSide = 1.0 - pow(abs(worldNormal.y), 0.65); + float underside = smoothstep(0.12, 0.82, -worldNormal.y); + float pageGlow = smoothstep(0.02, 0.18, worldPosition.y - bookTableTopY) * + (1.0 - smoothstep(0.18, 0.34, worldPosition.y - bookTableTopY)); + float bounce = tableReach * (0.42 + grazingSide * 0.34 + underside * 0.32) + pageGlow * 0.16; + vec3 tableWarmth = vec3(0.055, 0.029, 0.014); + vec3 pageWarmth = vec3(0.03, 0.021, 0.012); + return (tableWarmth * bounce + pageWarmth * pageGlow) * mix(1.0, 0.62, shadow); + } + float spineClothThread(float coordinate, float frequency, float sharpness) { float wave = abs(fract(coordinate * frequency) - 0.5) * 2.0; return pow(1.0 - wave, sharpness); @@ -554,14 +590,23 @@ function configureBookShadowReceiver(material, strength) { float fiber = clamp(fleck * 0.018 + cloud * 0.022, -0.04, 0.05); vec3 paperTint = mix(vec3(0.96, 0.945, 0.89), vec3(1.08, 1.055, 0.98), clamp(0.62 + fiber, 0.0, 1.0)); return baseLight * paperTint; + } + + vec3 headbandCreviceLight(vec2 uv, vec3 baseLight) { + float wrapRidge = spineClothThread(uv.x * 0.72 + uv.y * 4.8, 58.0, 0.7); + float fiber = spineClothThread(uv.y + uv.x * 0.08, 72.0, 1.35); + float relief = 0.82 + wrapRidge * 0.1 + fiber * 0.04; + return baseLight * relief; }` ) .replace( '#include ', `${isSpineCloth ? 'outgoingLight = spineClothLight(vBookSurfaceUv, outgoingLight);' : ''} ${isHardcoverPaper ? 'outgoingLight = hardcoverPaperLight(vBookSurfaceUv, outgoingLight);' : ''} + ${isHeadband ? 'outgoingLight = headbandCreviceLight(vBookSurfaceUv, outgoingLight);' : ''} float bookReceiverShadow = bookReceiverShadowField(vBookReceiverWorldPosition) * bookShadowReceiverStrength; - outgoingLight *= mix(vec3(1.0), vec3(0.38, 0.29, 0.2), bookReceiverShadow); + outgoingLight *= mix(vec3(1.0), ${isHeadband ? 'vec3(0.16, 0.095, 0.055)' : 'vec3(0.38, 0.29, 0.2)'}, bookReceiverShadow); + outgoingLight += bookLocalBounce(vBookReceiverWorldPosition, normalize(vBookReceiverWorldNormal), bookReceiverShadow); #include ` ); }; @@ -1256,6 +1301,7 @@ function buildBook() { coverSpineBase: materials.spineBaseLeather, coverEdge: materials.coverEdge, spine: materials.spineCloth, + headband: materials.headband, pageTop: materials.pageSurface, leftPage: materials.leftPage, rightPage: materials.rightPage @@ -1266,6 +1312,8 @@ function buildBook() { } const strength = part === 'spine' ? 0.48 + : part === 'headband' + ? 0.62 : part === 'coverSpineBase' ? 0.34 : part === 'hinge' @@ -2095,6 +2143,95 @@ function createSpineClothTextures() { return { color: colorTexture, normal: normalTexture, roughness: roughnessTexture }; } +function createHeadbandTextures() { + const width = 1024; + const height = 256; + const colorCanvas = document.createElement('canvas'); + const normalCanvas = document.createElement('canvas'); + const roughnessCanvas = document.createElement('canvas'); + colorCanvas.width = width; + colorCanvas.height = height; + normalCanvas.width = width; + normalCanvas.height = height; + roughnessCanvas.width = width; + roughnessCanvas.height = height; + const colorContext = colorCanvas.getContext('2d'); + const normalContext = normalCanvas.getContext('2d'); + const roughnessContext = roughnessCanvas.getContext('2d'); + const colorImage = colorContext.createImageData(width, height); + const normalImage = normalContext.createImageData(width, height); + const roughnessImage = roughnessContext.createImageData(width, height); + const threadAt = (x, y) => { + const u = x / width; + const v = y / height; + const wrap = u * 44 + v * 7.5; + const phase = wrap - Math.floor(wrap); + const rib = Math.pow(1 - Math.abs(phase - 0.5) * 2, 0.55); + const warp = Math.pow(1 - Math.abs(((u * 190 + v * 9) % 1) - 0.5) * 2, 1.1); + const weft = Math.pow(1 - Math.abs(((v * 38 + u * 4.5) % 1) - 0.5) * 2, 1.25); + return rib * 0.72 + warp * 0.16 + weft * 0.12; + }; + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const index = (y * width + x) * 4; + const u = x / width; + const v = y / height; + const wrap = u * 44 + v * 7.5; + const alternate = Math.floor(wrap) % 2; + const heightValue = threadAt(x, y); + const cotton = Math.sin((u * 410 + v * 79) * 6.28318530718) * 0.025; + const shade = THREE.MathUtils.clamp(0.76 + heightValue * 0.18 + cotton, 0.58, 1.0); + const red = [166, 30, 24]; + const ivory = [218, 190, 136]; + const linen = [152, 116, 82]; + const base = alternate === 0 ? red : ivory; + const blend = THREE.MathUtils.clamp(heightValue * 1.08, 0, 1); + colorImage.data[index] = Math.round(THREE.MathUtils.lerp(linen[0], base[0], blend) * shade); + colorImage.data[index + 1] = Math.round(THREE.MathUtils.lerp(linen[1], base[1], blend) * shade); + colorImage.data[index + 2] = Math.round(THREE.MathUtils.lerp(linen[2], base[2], blend) * shade); + colorImage.data[index + 3] = 255; + + const hLeft = threadAt((x - 1 + width) % width, y); + const hRight = threadAt((x + 1) % width, y); + const hDown = threadAt(x, (y - 1 + height) % height); + const hUp = threadAt(x, (y + 1) % height); + const normal = new THREE.Vector3((hLeft - hRight) * 3.8, (hDown - hUp) * 3.8, 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.74 + heightValue * 0.16 + Math.abs(hLeft - hRight) * 0.8, 0.58, 0.96); + 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(1.0, 1.0); + 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');