Add WebGL book headbands and bounce lighting

This commit is contained in:
2026-06-06 10:29:18 +02:00
parent 925caa57bb
commit 0956d2ef1f
2 changed files with 180 additions and 9 deletions
+38 -4
View File
@@ -68,6 +68,12 @@ function createBookContext(options) {
envMapIntensity: 0.08, envMapIntensity: 0.08,
side: THREE.DoubleSide 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({ pageTop: options.materials?.pageTop ?? new THREE.MeshStandardMaterial({
color: 0xf1dfba, color: 0xf1dfba,
roughness: 0.82, roughness: 0.82,
@@ -324,26 +330,53 @@ function addClothSpine(group, context, model) {
mesh.userData.bookPart = 'spine'; mesh.userData.bookPart = 'spine';
configurePartMaterial(context, mesh.material, 'spine'); configurePartMaterial(context, mesh.material, 'spine');
group.add(mesh); 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) { function createClothSpineGeometry(depth, spineWidth) {
const endOverrun = 0.0012;
const profile = []; const profile = [];
for (let i = 0; i <= 32; i += 1) { for (let i = 0; i <= 32; i += 1) {
profile.push(spineCurvePoint(i / 32, spineWidth)); profile.push(spineCurvePoint(i / 32, spineWidth));
} }
const positions = []; const positions = [];
const uvs = [];
const indices = []; const indices = [];
const front = []; const front = [];
const back = []; const back = [];
const push = (point, z) => { const push = (point, z, uv) => {
const index = positions.length / 3; const index = positions.length / 3;
positions.push(point.x, point.y, z); positions.push(point.x, point.y, z);
uvs.push(uv.u, uv.v);
return index; return index;
}; };
profile.forEach((point) => { profile.forEach((point, index) => {
front.push(push(point, depth * 0.5 + 0.024)); const u = profile.length <= 1 ? 0.5 : index / (profile.length - 1);
back.push(push(point, -depth * 0.5 - 0.024)); 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) { for (let i = 0; i < profile.length - 1; i += 1) {
indices.push(front[i], back[i], front[i + 1]); indices.push(front[i], back[i], front[i + 1]);
@@ -353,6 +386,7 @@ function createClothSpineGeometry(depth, spineWidth) {
const geometry = new THREE.BufferGeometry(); const geometry = new THREE.BufferGeometry();
geometry.setIndex(indices); geometry.setIndex(indices);
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
geometry.computeVertexNormals(); geometry.computeVertexNormals();
return geometry; return geometry;
} }
+142 -5
View File
@@ -170,6 +170,7 @@ const rightTexture = new THREE.CanvasTexture(rightCanvas);
}); });
const leatherTextures = createLeatherTextures(); const leatherTextures = createLeatherTextures();
const spineClothTextures = createSpineClothTextures(); const spineClothTextures = createSpineClothTextures();
const headbandTextures = createHeadbandTextures();
const paperTextures = createHardcoverPaperTextures(); const paperTextures = createHardcoverPaperTextures();
const materials = { const materials = {
@@ -284,9 +285,20 @@ const materials = {
metalness: 0, metalness: 0,
envMapIntensity: 0.075, envMapIntensity: 0.075,
side: THREE.DoubleSide 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.spineCloth.userData.isSpineCloth = true;
materials.headband.userData.isHeadband = true;
configureHardcoverPaperMaterial(materials.pageBlock); configureHardcoverPaperMaterial(materials.pageBlock);
configureHardcoverPaperMaterial(materials.pageEdge, { useEdgeMap: true }); configureHardcoverPaperMaterial(materials.pageEdge, { useEdgeMap: true });
configureHardcoverPaperMaterial(materials.pageSurface); configureHardcoverPaperMaterial(materials.pageSurface);
@@ -303,6 +315,7 @@ configureBookShadowReceiver(materials.pageSurface, 0.34);
configureBookShadowReceiver(materials.leftPage, 0.38); configureBookShadowReceiver(materials.leftPage, 0.38);
configureBookShadowReceiver(materials.rightPage, 0.38); configureBookShadowReceiver(materials.rightPage, 0.38);
configureBookShadowReceiver(materials.spineCloth, 0.48); configureBookShadowReceiver(materials.spineCloth, 0.48);
configureBookShadowReceiver(materials.headband, 0.62);
buildTable(); buildTable();
buildLighting(); buildLighting();
@@ -422,23 +435,31 @@ function loadUtilityTexture(url) {
function configureBookShadowReceiver(material, strength) { function configureBookShadowReceiver(material, strength) {
const isSpineCloth = material.userData?.isSpineCloth === true; const isSpineCloth = material.userData?.isSpineCloth === true;
const isHardcoverPaper = material.userData?.isHardcoverPaper === 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) => { material.onBeforeCompile = (shader) => {
shader.uniforms.bookShadowMaps = { value: bookShadowTargets.map((target) => target.texture) }; shader.uniforms.bookShadowMaps = { value: bookShadowTargets.map((target) => target.texture) };
shader.uniforms.bookShadowMatrices = { value: bookShadowMatrices }; shader.uniforms.bookShadowMatrices = { value: bookShadowMatrices };
shader.uniforms.bookShadowMapTexelSize = { value: new THREE.Vector2(1 / bookShadowMapSize, 1 / bookShadowMapSize) }; shader.uniforms.bookShadowMapTexelSize = { value: new THREE.Vector2(1 / bookShadowMapSize, 1 / bookShadowMapSize) };
shader.uniforms.bookShadowReceiverStrength = { value: strength }; shader.uniforms.bookShadowReceiverStrength = { value: strength };
shader.uniforms.bookTableTopY = { value: tableTopY };
shader.vertexShader = shader.vertexShader shader.vertexShader = shader.vertexShader
.replace( .replace(
'#include <common>', '#include <common>',
`#include <common> `#include <common>
varying vec3 vBookReceiverWorldPosition; varying vec3 vBookReceiverWorldPosition;
${isSpineCloth || isHardcoverPaper ? 'varying vec2 vBookSurfaceUv;' : ''}` varying vec3 vBookReceiverWorldNormal;
${isSpineCloth || isHardcoverPaper || isHeadband ? 'varying vec2 vBookSurfaceUv;' : ''}`
)
.replace(
'#include <defaultnormal_vertex>',
`#include <defaultnormal_vertex>
vBookReceiverWorldNormal = normalize(mat3(modelMatrix) * objectNormal);`
) )
.replace( .replace(
'#include <begin_vertex>', '#include <begin_vertex>',
`${isSpineCloth || isHardcoverPaper ? 'vBookSurfaceUv = uv;' : ''} `${isSpineCloth || isHardcoverPaper || isHeadband ? 'vBookSurfaceUv = uv;' : ''}
#include <begin_vertex>` #include <begin_vertex>`
) )
.replace( .replace(
@@ -454,8 +475,10 @@ function configureBookShadowReceiver(material, strength) {
uniform mat4 bookShadowMatrices[3]; uniform mat4 bookShadowMatrices[3];
uniform vec2 bookShadowMapTexelSize; uniform vec2 bookShadowMapTexelSize;
uniform float bookShadowReceiverStrength; uniform float bookShadowReceiverStrength;
uniform float bookTableTopY;
varying vec3 vBookReceiverWorldPosition; varying vec3 vBookReceiverWorldPosition;
${isSpineCloth || isHardcoverPaper ? 'varying vec2 vBookSurfaceUv;' : ''} varying vec3 vBookReceiverWorldNormal;
${isSpineCloth || isHardcoverPaper || isHeadband ? 'varying vec2 vBookSurfaceUv;' : ''}
float bookReceiverUnpackRGBADepth(vec4 packedDepth) { float bookReceiverUnpackRGBADepth(vec4 packedDepth) {
const vec4 unpackFactors = vec4( const vec4 unpackFactors = vec4(
@@ -524,6 +547,19 @@ function configureBookShadowReceiver(material, strength) {
return clamp(max(max(shadow0, shadow1), shadow2), 0.0, 1.0); 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 spineClothThread(float coordinate, float frequency, float sharpness) {
float wave = abs(fract(coordinate * frequency) - 0.5) * 2.0; float wave = abs(fract(coordinate * frequency) - 0.5) * 2.0;
return pow(1.0 - wave, sharpness); 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); 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)); 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; 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( .replace(
'#include <opaque_fragment>', '#include <opaque_fragment>',
`${isSpineCloth ? 'outgoingLight = spineClothLight(vBookSurfaceUv, outgoingLight);' : ''} `${isSpineCloth ? 'outgoingLight = spineClothLight(vBookSurfaceUv, outgoingLight);' : ''}
${isHardcoverPaper ? 'outgoingLight = hardcoverPaperLight(vBookSurfaceUv, outgoingLight);' : ''} ${isHardcoverPaper ? 'outgoingLight = hardcoverPaperLight(vBookSurfaceUv, outgoingLight);' : ''}
${isHeadband ? 'outgoingLight = headbandCreviceLight(vBookSurfaceUv, outgoingLight);' : ''}
float bookReceiverShadow = bookReceiverShadowField(vBookReceiverWorldPosition) * bookShadowReceiverStrength; 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 <opaque_fragment>` #include <opaque_fragment>`
); );
}; };
@@ -1256,6 +1301,7 @@ function buildBook() {
coverSpineBase: materials.spineBaseLeather, coverSpineBase: materials.spineBaseLeather,
coverEdge: materials.coverEdge, coverEdge: materials.coverEdge,
spine: materials.spineCloth, spine: materials.spineCloth,
headband: materials.headband,
pageTop: materials.pageSurface, pageTop: materials.pageSurface,
leftPage: materials.leftPage, leftPage: materials.leftPage,
rightPage: materials.rightPage rightPage: materials.rightPage
@@ -1266,6 +1312,8 @@ function buildBook() {
} }
const strength = part === 'spine' const strength = part === 'spine'
? 0.48 ? 0.48
: part === 'headband'
? 0.62
: part === 'coverSpineBase' : part === 'coverSpineBase'
? 0.34 ? 0.34
: part === 'hinge' : part === 'hinge'
@@ -2095,6 +2143,95 @@ function createSpineClothTextures() {
return { color: colorTexture, normal: normalTexture, roughness: roughnessTexture }; 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() { function createHardcoverPaperTextures() {
const size = 1024; const size = 1024;
const colorCanvas = document.createElement('canvas'); const colorCanvas = document.createElement('canvas');