Add WebGL book headbands and bounce lighting
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
+142
-5
@@ -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 <common>',
|
||||
`#include <common>
|
||||
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(
|
||||
'#include <begin_vertex>',
|
||||
`${isSpineCloth || isHardcoverPaper ? 'vBookSurfaceUv = uv;' : ''}
|
||||
`${isSpineCloth || isHardcoverPaper || isHeadband ? 'vBookSurfaceUv = uv;' : ''}
|
||||
#include <begin_vertex>`
|
||||
)
|
||||
.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 <opaque_fragment>',
|
||||
`${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 <opaque_fragment>`
|
||||
);
|
||||
};
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user