prophetk-3d-viewer
Version:
A Three.js r128 based GLB/GLTF model viewer for WeChat Mini Program and Web.
619 lines (560 loc) • 22.8 kB
JavaScript
/**
* DecalShader
* Injects decal projection onto existing MeshStandardMaterial
* via onBeforeCompile, preserving full PBR lighting.
*
* Projection model:
* - cylindrical: virtual cylinder aligned to world Y axis
* - projector: orthographic projector with world position, direction, width and height
*
* Usage:
* const decal = new DecalProjector(texture);
* decal.applyToModel(gltfScene);
* decal.setAngle(Math.PI / 2);
* decal.setHeight(0.5);
* decal.remove();
*/
// GLSL injected after map_fragment — overrides diffuseColor.rgb in decal region
const DECAL_FRAGMENT_COLOR = /* glsl */`
{
vec3 wp = vDecalWorldPos;
float u = 0.0;
float v = 0.0;
float isVisible = 1.0;
if (decalProjectionMode < 0.5) {
float angle = atan(wp.z, wp.x);
float angleDelta = atan(sin(angle - decalAngle), cos(angle - decalAngle));
u = angleDelta / decalArcWidth + 0.5;
v = (wp.y - decalHeight) / decalHeightRange / decalAspect + 0.5;
} else {
vec3 projectorLocal = wp - decalProjectorPosition;
vec3 worldNormal = normalize(vDecalWorldNormal);
u = dot(projectorLocal, decalProjectorRight) / decalProjectorWidth + 0.5;
v = dot(projectorLocal, decalProjectorUp) / decalProjectorHeight + 0.5;
isVisible = step(decalProjectorNormalThreshold, dot(worldNormal, -decalProjectorDirection));
u = (u - 0.5) / decalProjectorFitScaleX + 0.5;
v = (v - 0.5) / decalProjectorFitScaleY + 0.5;
}
u = (u - 0.5) / decalScaleX + 0.5;
v = (v - 0.5) / decalScaleY + 0.5;
if (u >= 0.0 && u <= 1.0 && v >= 0.0 && v <= 1.0) {
float sampleU = decalProjectionMode < 0.5 ? 1.0 - u : u;
vec4 dc = texture2D(decalTexture, vec2(sampleU, v));
dc = vec4(decalSRGBToLinear(dc.rgb), dc.a);
vDecalBlend = dc.a * decalOpacity * isVisible;
diffuseColor.rgb = mix(diffuseColor.rgb, dc.rgb, vDecalBlend);
// alpha: decal区域用贴花自己的alpha,不受底层材质透明度影响
diffuseColor.a = mix(diffuseColor.a, dc.a, vDecalBlend);
}
}
`;
const DECAL_FRAGMENT_PARS = /* glsl */`
vec3 decalSRGBToLinear(vec3 value) {
return vec3(
value.r <= 0.04045 ? value.r / 12.92 : pow((value.r + 0.055) / 1.055, 2.4),
value.g <= 0.04045 ? value.g / 12.92 : pow((value.g + 0.055) / 1.055, 2.4),
value.b <= 0.04045 ? value.b / 12.92 : pow((value.b + 0.055) / 1.055, 2.4)
);
}
`;
// injected after metalnessmap_fragment — override metalness in decal region
const DECAL_FRAGMENT_METALNESS = /* glsl */`
metalnessFactor = mix(metalnessFactor, decalMetalness, vDecalBlend);
`;
// injected after roughnessmap_fragment — override roughness in decal region
const DECAL_FRAGMENT_ROUGHNESS = /* glsl */`
roughnessFactor = mix(roughnessFactor, decalRoughness, vDecalBlend);
`;
// GLSL injected into the vertex shader to pass world position
const DECAL_VERTEX_INJECT = /* glsl */`
vDecalWorldPos = (modelMatrix * vec4(position, 1.0)).xyz;
vec3 decalViewNormal = normalize(normalMatrix * normal);
mat3 decalViewRotation = mat3(viewMatrix);
vDecalWorldNormal = normalize(vec3(
dot(decalViewRotation[0], decalViewNormal),
dot(decalViewRotation[1], decalViewNormal),
dot(decalViewRotation[2], decalViewNormal)
));
`;
const EPSILON = 0.0001;
function isProjectorMode(mode) {
return mode === 'projector' || mode === 'orthographic' || mode === 1 || mode === true;
}
function hasProjectorOptions(options = {}) {
return (
options.projectorPosition !== undefined ||
options.position !== undefined ||
options.projectorDirection !== undefined ||
options.direction !== undefined ||
options.projectorTarget !== undefined ||
options.targetPoint !== undefined ||
options.projectorRotation !== undefined ||
options.rotation !== undefined ||
options.projectorRoll !== undefined ||
options.roll !== undefined ||
options.projectorRollDeg !== undefined ||
options.rollDeg !== undefined ||
options.projectorWidth !== undefined ||
options.width !== undefined ||
options.projectorHeight !== undefined ||
options.projectorUp !== undefined ||
options.up !== undefined ||
options.projectorNormalThreshold !== undefined ||
options.normalThreshold !== undefined
);
}
function firstDefined() {
for (let i = 0; i < arguments.length; i += 1) {
if (arguments[i] !== undefined) return arguments[i];
}
return undefined;
}
function safePositive(value, fallback) {
const n = Number(value);
return Number.isFinite(n) ? Math.max(n, EPSILON) : fallback;
}
function resolveProjectorFitScale(projectorWidth, projectorHeight, textureWidth, textureHeight) {
const projectorAspect = Math.max(projectorWidth, EPSILON) / Math.max(projectorHeight, EPSILON);
const textureAspect = Math.max(textureWidth, 1) / Math.max(textureHeight, 1);
if (textureAspect > projectorAspect) {
return {
x: 1,
y: Math.max(projectorAspect / textureAspect, EPSILON),
};
}
return {
x: Math.max(textureAspect / projectorAspect, EPSILON),
y: 1,
};
}
function toVec3(value, fallback) {
if (!Array.isArray(value) || value.length < 3) return fallback.slice();
return [
Number(value[0]) || 0,
Number(value[1]) || 0,
Number(value[2]) || 0,
];
}
function dotVec3(a, b) {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}
function crossVec3(a, b) {
return [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
];
}
function subVec3(a, b) {
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
}
function normalizeVec3(value, fallback) {
const len = Math.sqrt(dotVec3(value, value));
if (!Number.isFinite(len) || len < EPSILON) return fallback.slice();
return [value[0] / len, value[1] / len, value[2] / len];
}
function rotateVec3AroundAxis(value, axis, rad) {
const angle = Number(rad) || 0;
if (Math.abs(angle) < EPSILON) return value.slice();
const c = Math.cos(angle);
const s = Math.sin(angle);
const axisDot = dotVec3(axis, value);
const axisCross = crossVec3(axis, value);
return [
value[0] * c + axisCross[0] * s + axis[0] * axisDot * (1 - c),
value[1] * c + axisCross[1] * s + axis[1] * axisDot * (1 - c),
value[2] * c + axisCross[2] * s + axis[2] * axisDot * (1 - c),
];
}
function rotateVec3ByEulerXYZ(value, rotation) {
const x = Number(rotation[0]) || 0;
const y = Number(rotation[1]) || 0;
const z = Number(rotation[2]) || 0;
const cx = Math.cos(x);
const sx = Math.sin(x);
const cy = Math.cos(y);
const sy = Math.sin(y);
const cz = Math.cos(z);
const sz = Math.sin(z);
let vx = value[0];
let vy = value[1] * cx - value[2] * sx;
let vz = value[1] * sx + value[2] * cx;
const tx = vx * cy + vz * sy;
vz = -vx * sy + vz * cy;
vx = tx;
return [
vx * cz - vy * sz,
vx * sz + vy * cz,
vz,
];
}
class DecalProjector {
/**
* @param {THREE.Texture} texture The decal texture (RGBA recommended)
* @param {object} options
* @param {number} options.arcWidth Angular width in radians (default π/3 ≈ 60°)
* @param {number} options.heightRange Height span in world units (default 1.0)
* @param {number} options.opacity Blend opacity 0–1 (default 1.0)
*/
constructor(texture, options = {}) {
const {
arcWidth = Math.PI / 3,
heightRange = 1.0,
opacity = 1.0,
scaleX = 1.0,
scaleY = 1.0,
cylinderRadius = 1.0,
textureWidth = 1,
textureHeight = 1,
} = options;
const safeTextureWidth = Math.max(textureWidth, 1);
const safeTextureHeight = Math.max(textureHeight, 1);
const safeArcWidth = Math.max(arcWidth, 0.0001);
const safeHeightRange = Math.max(heightRange, 0.0001);
const textureAspectFactor = safeTextureHeight / safeTextureWidth;
const decalWidth = Math.max(safeArcWidth * cylinderRadius, 0.0001);
const aspect = (decalWidth / safeHeightRange) * textureAspectFactor;
const projectionMode = firstDefined(
options.projectionMode,
options.mode,
hasProjectorOptions(options) ? 'projector' : undefined
);
const projector = this._resolveProjectorOptions(options, decalWidth, safeHeightRange);
const projectorFitScale = resolveProjectorFitScale(
projector.width,
projector.height,
safeTextureWidth,
safeTextureHeight
);
console.log('[Decal] radius:', cylinderRadius.toFixed(3), 'texRatio:', (safeTextureWidth / safeTextureHeight).toFixed(3), 'aspect:', aspect.toFixed(3));
this._texture = texture;
this._uniforms = {
decalTexture: { value: texture },
decalProjectionMode: { value: isProjectorMode(projectionMode) ? 1 : 0 },
decalAngle: { value: 0 },
decalHeight: { value: 0 },
decalArcWidth: { value: safeArcWidth },
decalWidth: { value: decalWidth },
decalHeightRange: { value: safeHeightRange },
decalOpacity: { value: opacity },
decalScaleX: { value: Math.max(scaleX, 0.0001) },
decalScaleY: { value: Math.max(scaleY, 0.0001) },
decalAspect: { value: aspect },
decalMetalness: { value: 0.0 },
decalRoughness: { value: 0.5 },
decalProjectorPosition: { value: projector.position },
decalProjectorDirection: { value: projector.direction },
decalProjectorRight: { value: projector.right },
decalProjectorUp: { value: projector.up },
decalProjectorWidth: { value: projector.width },
decalProjectorHeight: { value: projector.height },
decalProjectorFitScaleX: { value: projectorFitScale.x },
decalProjectorFitScaleY: { value: projectorFitScale.y },
decalProjectorNormalThreshold: { value: projector.normalThreshold },
};
this._cylinderRadius = cylinderRadius;
this._textureAspectFactor = textureAspectFactor;
this._textureWidth = safeTextureWidth;
this._textureHeight = safeTextureHeight;
this._patchedMaterials = [];
}
setOptions(options = {}) {
const mode = firstDefined(
options.projectionMode,
options.mode,
hasProjectorOptions(options) ? 'projector' : undefined
);
if (mode !== undefined) this.setProjectionMode(mode);
if (
options.projectorPosition !== undefined ||
options.position !== undefined ||
options.projectorDirection !== undefined ||
options.direction !== undefined ||
options.projectorTarget !== undefined ||
options.targetPoint !== undefined ||
options.projectorRotation !== undefined ||
options.rotation !== undefined ||
options.projectorRoll !== undefined ||
options.roll !== undefined ||
options.projectorRollDeg !== undefined ||
options.rollDeg !== undefined ||
options.projectorWidth !== undefined ||
options.width !== undefined ||
options.projectorHeight !== undefined ||
options.height !== undefined ||
options.projectorUp !== undefined ||
options.up !== undefined ||
options.normalThreshold !== undefined ||
options.projectorNormalThreshold !== undefined
) {
this.setProjector(options);
}
if (options.angle !== undefined) this.setAngle(options.angle);
if (options.decalHeight !== undefined) this.setHeight(options.decalHeight);
if (!isProjectorMode(mode) && options.height !== undefined) this.setHeight(options.height);
if (options.arcWidth !== undefined) this.setArcWidth(options.arcWidth);
if (options.heightRange !== undefined) this.setHeightRange(options.heightRange);
if (options.scaleX !== undefined) this.setScaleX(options.scaleX);
if (options.scaleY !== undefined) this.setScaleY(options.scaleY);
if (options.opacity !== undefined) this.setOpacity(options.opacity);
}
setProjectionMode(mode) {
this._uniforms.decalProjectionMode.value = isProjectorMode(mode) ? 1 : 0;
}
setProjector(options = {}) {
const projector = this._resolveProjectorOptions(
options,
this._uniforms.decalProjectorWidth.value,
this._uniforms.decalProjectorHeight.value
);
this._uniforms.decalProjectorPosition.value = projector.position;
this._uniforms.decalProjectorDirection.value = projector.direction;
this._uniforms.decalProjectorRight.value = projector.right;
this._uniforms.decalProjectorUp.value = projector.up;
this._uniforms.decalProjectorWidth.value = projector.width;
this._uniforms.decalProjectorHeight.value = projector.height;
const projectorFitScale = resolveProjectorFitScale(
projector.width,
projector.height,
this._textureWidth,
this._textureHeight
);
this._uniforms.decalProjectorFitScaleX.value = projectorFitScale.x;
this._uniforms.decalProjectorFitScaleY.value = projectorFitScale.y;
this._uniforms.decalProjectorNormalThreshold.value = projector.normalThreshold;
}
/** Angle (radians) of the decal center around the world Y axis. */
setAngle(rad) {
this._uniforms.decalAngle.value = rad;
}
/** World-Y coordinate of the decal center. */
setHeight(y) {
this._uniforms.decalHeight.value = y;
}
setArcWidth(rad) {
const safeArcWidth = Math.max(rad, 0.0001);
this._uniforms.decalArcWidth.value = safeArcWidth;
this._uniforms.decalWidth.value = Math.max(safeArcWidth * this._cylinderRadius, 0.0001);
this._updateAspect();
}
setHeightRange(h) {
this._uniforms.decalHeightRange.value = Math.max(h, 0.0001);
this._updateAspect();
}
/** Blend opacity 0–1. */
setOpacity(v) {
this._uniforms.decalOpacity.value = v;
}
/** Horizontal scale (>1 stretches, <1 shrinks). */
setScaleX(v) {
this._uniforms.decalScaleX.value = Math.max(v, 0.0001);
}
/** Vertical scale (>1 stretches, <1 shrinks). */
setScaleY(v) {
this._uniforms.decalScaleY.value = Math.max(v, 0.0001);
}
/** Metalness of the decal surface (0–1). */
setMetalness(v) { this._uniforms.decalMetalness.value = v; }
/** Roughness of the decal surface (0–1). */
setRoughness(v) { this._uniforms.decalRoughness.value = v; }
/** / MeshPhysicalMaterial in the model.
* Safe to call multiple times — already-patched materials are skipped.
* @param {THREE.Object3D} model
*/
applyToModel(model) {
require('./modelTraversal.js')(model, (obj) => {
if (!obj.isMesh) return;
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
mats.forEach((mat) => {
if (!mat || mat._decalPatched) return;
this._patchMaterial(mat);
});
});
}
/**
* Remove decal from all patched materials.
* Restores original onBeforeCompile and forces shader recompile.
*/
remove() {
this._patchedMaterials.forEach((mat) => {
this._restoreMaterial(mat);
});
this._patchedMaterials = [];
}
removeFromModel(model) {
if (!model) return;
const keep = [];
this._patchedMaterials.forEach((mat) => {
if (this._modelUsesMaterial(model, mat)) {
this._restoreMaterial(mat);
} else {
keep.push(mat);
}
});
this._patchedMaterials = keep;
}
dispose() {
this.remove();
if (this._texture) this._texture.dispose();
}
// ── Private ──────────────────────────────────────────────────────────────
_patchMaterial(mat) {
const uniforms = this._uniforms;
const prevCompile = mat.onBeforeCompile;
mat._originalOnBeforeCompile = prevCompile;
mat._originalCustomProgramCacheKey = mat.customProgramCacheKey;
mat.onBeforeCompile = (shader) => {
if (prevCompile) prevCompile(shader);
Object.assign(shader.uniforms, uniforms);
// Vertex: declare varying + inject world pos
shader.vertexShader = shader.vertexShader
.replace(
'void main() {',
'varying vec3 vDecalWorldPos;\nvarying vec3 vDecalWorldNormal;\nvoid main() {'
)
.replace(
'#include <project_vertex>',
'#include <project_vertex>\n' + DECAL_VERTEX_INJECT
);
// Fragment: declare uniforms + varyings + inject blends
shader.fragmentShader = shader.fragmentShader
.replace(
'void main() {',
[
'uniform sampler2D decalTexture;',
'uniform float decalProjectionMode;',
'uniform float decalAngle;',
'uniform float decalHeight;',
'uniform float decalArcWidth;',
'uniform float decalHeightRange;',
'uniform float decalOpacity;',
'uniform float decalScaleX;',
'uniform float decalScaleY;',
'uniform float decalAspect;',
'uniform float decalMetalness;',
'uniform float decalRoughness;',
'uniform vec3 decalProjectorPosition;',
'uniform vec3 decalProjectorDirection;',
'uniform vec3 decalProjectorRight;',
'uniform vec3 decalProjectorUp;',
'uniform float decalProjectorWidth;',
'uniform float decalProjectorHeight;',
'uniform float decalProjectorFitScaleX;',
'uniform float decalProjectorFitScaleY;',
'uniform float decalProjectorNormalThreshold;',
'varying vec3 vDecalWorldPos;',
'varying vec3 vDecalWorldNormal;',
DECAL_FRAGMENT_PARS,
'float vDecalBlend = 0.0;',
'void main() {',
].join('\n')
)
.replace(
'#include <map_fragment>',
'#include <map_fragment>\n' + DECAL_FRAGMENT_COLOR
)
.replace(
'#include <metalnessmap_fragment>',
'#include <metalnessmap_fragment>\n' + DECAL_FRAGMENT_METALNESS
)
.replace(
'#include <roughnessmap_fragment>',
'#include <roughnessmap_fragment>\n' + DECAL_FRAGMENT_ROUGHNESS
);
// Force shader recompile — customProgramCacheKey makes Three.js treat this
// as a distinct program even if the base material was already compiled.
};
const prevCacheKey = mat._originalCustomProgramCacheKey;
mat.customProgramCacheKey = () => {
const baseKey = prevCacheKey ? prevCacheKey.call(mat) : '';
return baseKey + '|decal_projector_v3';
};
mat._decalPatched = true;
mat.needsUpdate = true;
this._patchedMaterials.push(mat);
}
_restoreMaterial(mat) {
mat.onBeforeCompile = mat._originalOnBeforeCompile || (() => {});
if (mat._originalCustomProgramCacheKey) {
mat.customProgramCacheKey = mat._originalCustomProgramCacheKey;
} else {
delete mat.customProgramCacheKey;
}
mat._decalPatched = false;
mat.needsUpdate = true;
}
_resolveProjectorOptions(options = {}, defaultWidth, defaultHeight) {
const position = toVec3(
firstDefined(options.projectorPosition, options.position),
[0, 0, 1]
);
const targetValue = firstDefined(options.projectorTarget, options.targetPoint);
let direction;
if (targetValue !== undefined) {
direction = subVec3(toVec3(targetValue, [0, 0, 0]), position);
} else {
const directionValue = firstDefined(options.projectorDirection, options.direction);
direction = directionValue !== undefined
? toVec3(directionValue, [0, 0, -1])
: [0, 0, -1];
}
const rotationValue = firstDefined(options.projectorRotation, options.rotation);
if (targetValue === undefined && rotationValue !== undefined) {
direction = rotateVec3ByEulerXYZ([0, 0, -1], toVec3(rotationValue, [0, 0, 0]));
}
direction = normalizeVec3(direction, [0, 0, -1]);
let upHint = toVec3(firstDefined(options.projectorUp, options.up), [0, 1, 0]);
if (rotationValue !== undefined && firstDefined(options.projectorUp, options.up) === undefined) {
upHint = rotateVec3ByEulerXYZ([0, 1, 0], toVec3(rotationValue, [0, 0, 0]));
}
let right = normalizeVec3(crossVec3(direction, upHint), [1, 0, 0]);
if (Math.abs(dotVec3(right, direction)) > 0.01) {
const alternateUp = Math.abs(direction[1]) > 0.9 ? [0, 0, 1] : [0, 1, 0];
right = normalizeVec3(crossVec3(direction, alternateUp), [1, 0, 0]);
}
let up = normalizeVec3(crossVec3(right, direction), [0, 1, 0]);
const rollValue = firstDefined(options.projectorRoll, options.roll);
const rollDegValue = firstDefined(options.projectorRollDeg, options.rollDeg);
let roll = Number(rollValue);
if (!Number.isFinite(roll) && rollDegValue !== undefined) {
const rollDeg = Number(rollDegValue);
roll = Number.isFinite(rollDeg) ? rollDeg * Math.PI / 180 : 0;
}
if (Number.isFinite(roll) && Math.abs(roll) >= EPSILON) {
right = normalizeVec3(rotateVec3AroundAxis(right, direction, roll), right);
up = normalizeVec3(rotateVec3AroundAxis(up, direction, roll), up);
}
const widthValue = firstDefined(options.projectorWidth, options.width);
const heightValue = firstDefined(options.projectorHeight, options.height);
const threshold = Number(firstDefined(
options.projectorNormalThreshold,
options.normalThreshold,
0.0
));
return {
position,
direction,
right,
up,
width: safePositive(widthValue, Math.max(defaultWidth, EPSILON)),
height: safePositive(heightValue, Math.max(defaultHeight, EPSILON)),
normalThreshold: Number.isFinite(threshold) ? threshold : 0.0,
};
}
_updateAspect() {
const heightRange = Math.max(this._uniforms.decalHeightRange.value, 0.0001);
this._uniforms.decalAspect.value = (
this._uniforms.decalWidth.value / heightRange
) * this._textureAspectFactor;
}
_modelUsesMaterial(model, material) {
let found = false;
require('./modelTraversal.js')(model, (obj) => {
if (found || !obj.isMesh || !obj.material) return;
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
found = mats.indexOf(material) !== -1;
});
return found;
}
}
module.exports = DecalProjector;