UNPKG

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
/** * 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;