Port floorplan item thumbnails, full mobile editor UI, and mobile polish from the private monorepo into the public editor. Monorepo PR #297 (feat/item-admin-advanced): - Add optional floorPlanUrl to item asset schema (core) - Render 2D floor-plan images inside item footprints on floorplan - Improve slider drag with modifier key re-anchoring Monorepo PR #302 (feat/mobile-ui): - Mobile editor layout with draggable bottom sheet - Mobile tab bar, selection bar, and panel sheet - Mobile-aware panel manager and panel wrapper - useIsMobile rewrite (useSyncExternalStore, SSR-safe) - Camera actions hideOrbit prop - GridSnapControl and SecondaryToggles exports - Action menu hides on mobile contextual tabs - SidebarTab extended with mobileDefaultSnap/mobileIcon Monorepo PR #306 (feat/mobile-ui-polish): - Touch gesture mapping for camera controls (one/two/three finger) - Snap ratio constants for bottom sheet - Thumbnail generator WebGL2 fallback (bottom-up row flip) - ErrorBoundary scope logging, viewer scene wrap - GPUDeviceWatcher enhanced logging and uncaptured error handler - WebGPURenderer init error handling and diagnostics - Post-processing log improvements - MergedOutlineNode WebGL2 FBO corruption fix Excluded: apps/community/*, apps/editor/*, packages/community-*, .cursor/*, .env.example (monorepo-only files) Co-authored-by: Pascal <open@pascal.app>
642 lines
23 KiB
TypeScript
642 lines
23 KiB
TypeScript
// @ts-nocheck — Three.js TSL/WebGPU internal APIs have incomplete type definitions;
|
||
// this file is a fork of OutlineNode and is intentionally exempt from strict TS checking.
|
||
|
||
/**
|
||
* MergedOutlineNode — a fork of Three.js OutlineNode that processes two object
|
||
* groups (primary = selected, secondary = hovered) in a single pass, sharing the
|
||
* expensive non-selected depth pre-render between both groups.
|
||
*
|
||
* Cost comparison vs two separate OutlineNode instances:
|
||
* Before: depth_A + mask_A + edge_A×6 + depth_B + mask_B + edge_B×6 = 2 depth passes
|
||
* After: depth_AB (shared) + mask_A + edge_A×6 + mask_B + edge_B×6 = 1 depth pass
|
||
*
|
||
* Additional early-outs:
|
||
* - Both empty → skip everything (0 passes)
|
||
* - Only primary → skip secondary mask/edge/blur
|
||
* - Only secondary → skip primary mask/edge/blur
|
||
*/
|
||
|
||
import { DepthTexture, FloatType, type Object3D, RenderTarget, Vector2 } from 'three'
|
||
import {
|
||
color,
|
||
exp,
|
||
Fn,
|
||
float,
|
||
int,
|
||
Loop,
|
||
min,
|
||
mul,
|
||
nodeObject,
|
||
orthographicDepthToViewZ,
|
||
passTexture,
|
||
perspectiveDepthToViewZ,
|
||
positionView,
|
||
reference,
|
||
screenUV,
|
||
texture,
|
||
textureSize,
|
||
uniform,
|
||
uv,
|
||
vec2,
|
||
vec3,
|
||
vec4,
|
||
} from 'three/tsl'
|
||
import {
|
||
NodeMaterial,
|
||
NodeUpdateType,
|
||
QuadMesh,
|
||
RendererUtils,
|
||
SpriteNodeMaterial,
|
||
TempNode,
|
||
} from 'three/webgpu'
|
||
|
||
const _quadMesh = new QuadMesh()
|
||
const _size = new Vector2()
|
||
const _BLUR_X = new Vector2(1.0, 0.0)
|
||
const _BLUR_Y = new Vector2(0.0, 1.0)
|
||
let _rendererState: any // eslint-disable-line @typescript-eslint/no-explicit-any
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helper: render targets for one outline group
|
||
// ---------------------------------------------------------------------------
|
||
function makeGroupTargets(downSampleRatio: number) {
|
||
const maskBuffer = new RenderTarget()
|
||
const maskDownSample = new RenderTarget(1, 1, { depthBuffer: false })
|
||
const edgeBuffer1 = new RenderTarget(1, 1, { depthBuffer: false })
|
||
const edgeBuffer2 = new RenderTarget(1, 1, { depthBuffer: false })
|
||
const blurBuffer1 = new RenderTarget(1, 1, { depthBuffer: false })
|
||
const blurBuffer2 = new RenderTarget(1, 1, { depthBuffer: false })
|
||
const composite = new RenderTarget(1, 1, { depthBuffer: false })
|
||
|
||
function setSize(w: number, h: number) {
|
||
maskBuffer.setSize(w, h)
|
||
composite.setSize(w, h)
|
||
let rx = Math.round(w / downSampleRatio)
|
||
let ry = Math.round(h / downSampleRatio)
|
||
maskDownSample.setSize(rx, ry)
|
||
edgeBuffer1.setSize(rx, ry)
|
||
blurBuffer1.setSize(rx, ry)
|
||
rx = Math.round(rx / 2)
|
||
ry = Math.round(ry / 2)
|
||
edgeBuffer2.setSize(rx, ry)
|
||
blurBuffer2.setSize(rx, ry)
|
||
}
|
||
|
||
function dispose() {
|
||
maskBuffer.dispose()
|
||
maskDownSample.dispose()
|
||
edgeBuffer1.dispose()
|
||
edgeBuffer2.dispose()
|
||
blurBuffer1.dispose()
|
||
blurBuffer2.dispose()
|
||
composite.dispose()
|
||
}
|
||
|
||
return {
|
||
maskBuffer,
|
||
maskDownSample,
|
||
edgeBuffer1,
|
||
edgeBuffer2,
|
||
blurBuffer1,
|
||
blurBuffer2,
|
||
composite,
|
||
setSize,
|
||
dispose,
|
||
}
|
||
}
|
||
|
||
type GroupTargets = ReturnType<typeof makeGroupTargets>
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// MergedOutlineNode
|
||
// ---------------------------------------------------------------------------
|
||
export class MergedOutlineNode extends TempNode {
|
||
static get type() {
|
||
return 'MergedOutlineNode'
|
||
}
|
||
|
||
scene: any
|
||
camera: any
|
||
primaryObjects: Object3D[]
|
||
secondaryObjects: Object3D[]
|
||
primaryEdgeThicknessNode: any
|
||
secondaryEdgeThicknessNode: any
|
||
primaryEdgeGlowNode: any
|
||
secondaryEdgeGlowNode: any
|
||
downSampleRatio: number
|
||
updateBeforeType: string
|
||
|
||
private readonly _depthRT: RenderTarget
|
||
private readonly _depthTexUniform: any
|
||
|
||
private readonly _groupA: GroupTargets
|
||
private readonly _groupB: GroupTargets
|
||
private readonly _maskTexA: any
|
||
private readonly _maskDownTexA: any
|
||
private readonly _edge1TexA: any
|
||
private readonly _edge2TexA: any
|
||
private readonly _blurColorTexA: any
|
||
private readonly _maskTexB: any
|
||
private readonly _maskDownTexB: any
|
||
private readonly _edge1TexB: any
|
||
private readonly _edge2TexB: any
|
||
private readonly _blurColorTexB: any
|
||
private readonly _blurDirectionA: any
|
||
private readonly _blurDirectionB: any
|
||
private readonly _cameraNear: any
|
||
private readonly _cameraFar: any
|
||
|
||
private readonly _depthMaterial: NodeMaterial
|
||
private readonly _depthSpriteMaterial: SpriteNodeMaterial
|
||
private readonly _prepareMaskMatA: NodeMaterial
|
||
private readonly _prepareMaskSpriteMatA: SpriteNodeMaterial
|
||
private readonly _copyMatA: NodeMaterial
|
||
private readonly _edgeDetectMatA: NodeMaterial
|
||
private readonly _blurMat1A: NodeMaterial
|
||
private readonly _blurMat2A: NodeMaterial
|
||
private readonly _compositeMatA: NodeMaterial
|
||
private readonly _prepareMaskMatB: NodeMaterial
|
||
private readonly _prepareMaskSpriteMatB: SpriteNodeMaterial
|
||
private readonly _copyMatB: NodeMaterial
|
||
private readonly _edgeDetectMatB: NodeMaterial
|
||
private readonly _blurMat1B: NodeMaterial
|
||
private readonly _blurMat2B: NodeMaterial
|
||
private readonly _compositeMatB: NodeMaterial
|
||
|
||
private readonly _cacheA = new Set<Object3D>()
|
||
private readonly _cacheB = new Set<Object3D>()
|
||
|
||
// Tracks whether either group rendered last frame. We use this to decide
|
||
// when it's safe to skip renderer state manipulation entirely — touching
|
||
// the renderer (resetRendererAndSceneState + setRenderTarget + clearColor)
|
||
// corrupts the FBO state on the WebGL2 backend (iOS Chrome fallback) and
|
||
// the subsequent scene render comes out blank.
|
||
private _wroteGroupALastFrame = false
|
||
private _wroteGroupBLastFrame = false
|
||
|
||
private readonly _textureNodeA: any
|
||
private readonly _textureNodeB: any
|
||
|
||
constructor(
|
||
scene: any,
|
||
camera: any,
|
||
params: {
|
||
primaryObjects?: Object3D[]
|
||
secondaryObjects?: Object3D[]
|
||
primaryEdgeThickness?: any
|
||
secondaryEdgeThickness?: any
|
||
primaryEdgeGlow?: any
|
||
secondaryEdgeGlow?: any
|
||
downSampleRatio?: number
|
||
} = {},
|
||
) {
|
||
super('vec4')
|
||
|
||
const {
|
||
primaryObjects = [],
|
||
secondaryObjects = [],
|
||
primaryEdgeThickness = float(1),
|
||
secondaryEdgeThickness = float(1),
|
||
primaryEdgeGlow = float(0),
|
||
secondaryEdgeGlow = float(0),
|
||
downSampleRatio = 2,
|
||
} = params
|
||
|
||
this.scene = scene
|
||
this.camera = camera
|
||
this.primaryObjects = primaryObjects
|
||
this.secondaryObjects = secondaryObjects
|
||
this.primaryEdgeThicknessNode = nodeObject(primaryEdgeThickness)
|
||
this.secondaryEdgeThicknessNode = nodeObject(secondaryEdgeThickness)
|
||
this.primaryEdgeGlowNode = nodeObject(primaryEdgeGlow)
|
||
this.secondaryEdgeGlowNode = nodeObject(secondaryEdgeGlow)
|
||
this.downSampleRatio = downSampleRatio
|
||
this.updateBeforeType = NodeUpdateType.FRAME
|
||
|
||
this._depthRT = new RenderTarget()
|
||
this._depthRT.depthTexture = new DepthTexture()
|
||
this._depthRT.depthTexture.type = FloatType
|
||
|
||
this._groupA = makeGroupTargets(downSampleRatio)
|
||
this._groupB = makeGroupTargets(downSampleRatio)
|
||
|
||
this._cameraNear = reference('near', 'float', camera)
|
||
this._cameraFar = reference('far', 'float', camera)
|
||
this._blurDirectionA = uniform(new Vector2())
|
||
this._blurDirectionB = uniform(new Vector2())
|
||
this._depthTexUniform = texture(this._depthRT.depthTexture)
|
||
|
||
this._maskTexA = texture(this._groupA.maskBuffer.texture)
|
||
this._maskDownTexA = texture(this._groupA.maskDownSample.texture)
|
||
this._edge1TexA = texture(this._groupA.edgeBuffer1.texture)
|
||
this._edge2TexA = texture(this._groupA.edgeBuffer2.texture)
|
||
this._blurColorTexA = texture(this._groupA.edgeBuffer1.texture)
|
||
|
||
this._maskTexB = texture(this._groupB.maskBuffer.texture)
|
||
this._maskDownTexB = texture(this._groupB.maskDownSample.texture)
|
||
this._edge1TexB = texture(this._groupB.edgeBuffer1.texture)
|
||
this._edge2TexB = texture(this._groupB.edgeBuffer2.texture)
|
||
this._blurColorTexB = texture(this._groupB.edgeBuffer1.texture)
|
||
|
||
this._depthMaterial = new NodeMaterial()
|
||
this._depthMaterial.colorNode = color(0, 0, 0)
|
||
this._depthMaterial.name = 'MergedOutline.depth'
|
||
this._depthSpriteMaterial = new SpriteNodeMaterial()
|
||
this._depthSpriteMaterial.colorNode = color(0, 0, 0)
|
||
this._depthSpriteMaterial.name = 'MergedOutline.depthSprite'
|
||
|
||
this._prepareMaskMatA = new NodeMaterial()
|
||
this._prepareMaskMatA.name = 'MergedOutline.maskA'
|
||
this._prepareMaskSpriteMatA = new SpriteNodeMaterial()
|
||
this._prepareMaskSpriteMatA.name = 'MergedOutline.maskSpriteA'
|
||
this._copyMatA = new NodeMaterial()
|
||
this._copyMatA.name = 'MergedOutline.copyA'
|
||
this._edgeDetectMatA = new NodeMaterial()
|
||
this._edgeDetectMatA.name = 'MergedOutline.edgeA'
|
||
this._blurMat1A = new NodeMaterial()
|
||
this._blurMat1A.name = 'MergedOutline.blur1A'
|
||
this._blurMat2A = new NodeMaterial()
|
||
this._blurMat2A.name = 'MergedOutline.blur2A'
|
||
this._compositeMatA = new NodeMaterial()
|
||
this._compositeMatA.name = 'MergedOutline.compositeA'
|
||
|
||
this._prepareMaskMatB = new NodeMaterial()
|
||
this._prepareMaskMatB.name = 'MergedOutline.maskB'
|
||
this._prepareMaskSpriteMatB = new SpriteNodeMaterial()
|
||
this._prepareMaskSpriteMatB.name = 'MergedOutline.maskSpriteB'
|
||
this._copyMatB = new NodeMaterial()
|
||
this._copyMatB.name = 'MergedOutline.copyB'
|
||
this._edgeDetectMatB = new NodeMaterial()
|
||
this._edgeDetectMatB.name = 'MergedOutline.edgeB'
|
||
this._blurMat1B = new NodeMaterial()
|
||
this._blurMat1B.name = 'MergedOutline.blur1B'
|
||
this._blurMat2B = new NodeMaterial()
|
||
this._blurMat2B.name = 'MergedOutline.blur2B'
|
||
this._compositeMatB = new NodeMaterial()
|
||
this._compositeMatB.name = 'MergedOutline.compositeB'
|
||
|
||
// Output: R = visibleEdge, G = hiddenEdge
|
||
this._textureNodeA = passTexture(this, this._groupA.composite.texture)
|
||
this._textureNodeB = passTexture(this, this._groupB.composite.texture)
|
||
}
|
||
|
||
get primaryVisibleEdge() {
|
||
return this._textureNodeA.r
|
||
}
|
||
get primaryHiddenEdge() {
|
||
return this._textureNodeA.g
|
||
}
|
||
get secondaryVisibleEdge() {
|
||
return this._textureNodeB.r
|
||
}
|
||
get secondaryHiddenEdge() {
|
||
return this._textureNodeB.g
|
||
}
|
||
|
||
setSize(width: number, height: number) {
|
||
this._depthRT.setSize(width, height)
|
||
this._groupA.setSize(width, height)
|
||
this._groupB.setSize(width, height)
|
||
}
|
||
|
||
updateBefore(frame: any) {
|
||
const hasPrimary = this.primaryObjects.length > 0
|
||
const hasSecondary = this.secondaryObjects.length > 0
|
||
const hasAny = hasPrimary || hasSecondary
|
||
|
||
// Fast-path: nothing to render and nothing was rendered last frame either,
|
||
// so there are no stale composites to clear. Touch nothing — on the WebGL2
|
||
// backend (iOS Chrome fallback) even an empty reset/setRenderTarget cycle
|
||
// corrupts the framebuffer state and the next scene render goes blank.
|
||
const needsCleanupA = !hasPrimary && this._wroteGroupALastFrame
|
||
const needsCleanupB = !hasSecondary && this._wroteGroupBLastFrame
|
||
if (!(hasAny || needsCleanupA || needsCleanupB)) {
|
||
return
|
||
}
|
||
|
||
const { renderer } = frame
|
||
const { camera, scene } = this
|
||
|
||
_rendererState = RendererUtils.resetRendererAndSceneState(renderer, scene, _rendererState)
|
||
|
||
const size = renderer.getDrawingBufferSize(_size)
|
||
this.setSize(size.width, size.height)
|
||
|
||
// Clear composites for groups that just transitioned from "has content"
|
||
// to "empty" — without this, the previous outline lingers on the GPU.
|
||
if (needsCleanupA) {
|
||
renderer.setRenderTarget(this._groupA.composite)
|
||
renderer.clearColor()
|
||
this._wroteGroupALastFrame = false
|
||
}
|
||
if (needsCleanupB) {
|
||
renderer.setRenderTarget(this._groupB.composite)
|
||
renderer.clearColor()
|
||
this._wroteGroupBLastFrame = false
|
||
}
|
||
|
||
if (!hasAny) {
|
||
RendererUtils.restoreRendererAndSceneState(renderer, scene, _rendererState)
|
||
return
|
||
}
|
||
|
||
renderer.setClearColor(0xff_ff_ff, 1)
|
||
this._wroteGroupALastFrame = hasPrimary
|
||
this._wroteGroupBLastFrame = hasSecondary
|
||
|
||
if (hasPrimary) this._buildCache(this.primaryObjects, this._cacheA)
|
||
if (hasSecondary) this._buildCache(this.secondaryObjects, this._cacheB)
|
||
|
||
const savedName = scene.name
|
||
|
||
// ── 1. Shared depth pass: all objects NOT in either group ─────────────────
|
||
renderer.setRenderTarget(this._depthRT)
|
||
renderer.setRenderObjectFunction(
|
||
(obj: any, sc: any, cam: any, geo: any, _mat: any, grp: any, lights: any, clip: any) => {
|
||
const inCache = this._cacheA.has(obj) || this._cacheB.has(obj)
|
||
if (!inCache) {
|
||
const m = obj.isSprite ? this._depthSpriteMaterial : this._depthMaterial
|
||
renderer.renderObject(obj, sc, cam, geo, m, grp, lights, clip)
|
||
}
|
||
},
|
||
)
|
||
scene.name = 'MergedOutline [ Depth ]'
|
||
renderer.render(scene, camera)
|
||
|
||
// ── 2a. Primary mask pass ─────────────────────────────────────────────────
|
||
if (hasPrimary) {
|
||
renderer.setRenderTarget(this._groupA.maskBuffer)
|
||
renderer.setRenderObjectFunction(
|
||
(obj: any, sc: any, cam: any, geo: any, _mat: any, grp: any, lights: any, clip: any) => {
|
||
if (this._cacheA.has(obj)) {
|
||
const m = obj.isSprite ? this._prepareMaskSpriteMatA : this._prepareMaskMatA
|
||
renderer.renderObject(obj, sc, cam, geo, m, grp, lights, clip)
|
||
}
|
||
},
|
||
)
|
||
scene.name = 'MergedOutline [ Mask A ]'
|
||
renderer.render(scene, camera)
|
||
}
|
||
|
||
// ── 2b. Secondary mask pass ───────────────────────────────────────────────
|
||
if (hasSecondary) {
|
||
renderer.setRenderTarget(this._groupB.maskBuffer)
|
||
renderer.setRenderObjectFunction(
|
||
(obj: any, sc: any, cam: any, geo: any, _mat: any, grp: any, lights: any, clip: any) => {
|
||
if (this._cacheB.has(obj)) {
|
||
const m = obj.isSprite ? this._prepareMaskSpriteMatB : this._prepareMaskMatB
|
||
renderer.renderObject(obj, sc, cam, geo, m, grp, lights, clip)
|
||
}
|
||
},
|
||
)
|
||
scene.name = 'MergedOutline [ Mask B ]'
|
||
renderer.render(scene, camera)
|
||
}
|
||
|
||
renderer.setRenderObjectFunction(_rendererState.renderObjectFunction)
|
||
this._cacheA.clear()
|
||
this._cacheB.clear()
|
||
scene.name = savedName
|
||
|
||
// ── 3–7. Edge detect + blur + composite per active group ──────────────────
|
||
if (hasPrimary) this._runEdgePipeline(renderer, 'A')
|
||
if (hasSecondary) this._runEdgePipeline(renderer, 'B')
|
||
|
||
RendererUtils.restoreRendererAndSceneState(renderer, scene, _rendererState)
|
||
}
|
||
|
||
private _runEdgePipeline(renderer: any, group: 'A' | 'B') {
|
||
const isA = group === 'A'
|
||
const g = isA ? this._groupA : this._groupB
|
||
const copyMat = isA ? this._copyMatA : this._copyMatB
|
||
const edgeMat = isA ? this._edgeDetectMatA : this._edgeDetectMatB
|
||
const blur1 = isA ? this._blurMat1A : this._blurMat1B
|
||
const blur2 = isA ? this._blurMat2A : this._blurMat2B
|
||
const blurDir = isA ? this._blurDirectionA : this._blurDirectionB
|
||
const blurColorTex = isA ? this._blurColorTexA : this._blurColorTexB
|
||
const compositeMat = isA ? this._compositeMatA : this._compositeMatB
|
||
|
||
_quadMesh.material = copyMat
|
||
renderer.setRenderTarget(g.maskDownSample)
|
||
_quadMesh.render(renderer)
|
||
|
||
_quadMesh.material = edgeMat
|
||
renderer.setRenderTarget(g.edgeBuffer1)
|
||
_quadMesh.render(renderer)
|
||
|
||
blurColorTex.value = g.edgeBuffer1.texture
|
||
blurDir.value.copy(_BLUR_X)
|
||
_quadMesh.material = blur1
|
||
renderer.setRenderTarget(g.blurBuffer1)
|
||
_quadMesh.render(renderer)
|
||
|
||
blurColorTex.value = g.blurBuffer1.texture
|
||
blurDir.value.copy(_BLUR_Y)
|
||
renderer.setRenderTarget(g.edgeBuffer1)
|
||
_quadMesh.render(renderer)
|
||
|
||
blurColorTex.value = g.edgeBuffer1.texture
|
||
blurDir.value.copy(_BLUR_X)
|
||
_quadMesh.material = blur2
|
||
renderer.setRenderTarget(g.blurBuffer2)
|
||
_quadMesh.render(renderer)
|
||
|
||
blurColorTex.value = g.blurBuffer2.texture
|
||
blurDir.value.copy(_BLUR_Y)
|
||
renderer.setRenderTarget(g.edgeBuffer2)
|
||
_quadMesh.render(renderer)
|
||
|
||
_quadMesh.material = compositeMat
|
||
renderer.setRenderTarget(g.composite)
|
||
_quadMesh.render(renderer)
|
||
}
|
||
|
||
setup(_builder: any) {
|
||
// ── prepareMask ───────────────────────────────────────────────────────────
|
||
const buildPrepareMask = () => {
|
||
const depth = this._depthTexUniform.sample(screenUV)
|
||
const viewZ = this.camera.isPerspectiveCamera
|
||
? perspectiveDepthToViewZ(depth, this._cameraNear, this._cameraFar)
|
||
: orthographicDepthToViewZ(depth, this._cameraNear, this._cameraFar)
|
||
const depthTest = positionView.z.lessThanEqual(viewZ).select(1, 0)
|
||
return vec3(0.0, depthTest, 1.0)
|
||
}
|
||
|
||
const maskColorA = buildPrepareMask()
|
||
this._prepareMaskMatA.colorNode = maskColorA
|
||
this._prepareMaskMatA.needsUpdate = true
|
||
this._prepareMaskSpriteMatA.colorNode = maskColorA
|
||
this._prepareMaskSpriteMatA.needsUpdate = true
|
||
|
||
const maskColorB = buildPrepareMask()
|
||
this._prepareMaskMatB.colorNode = maskColorB
|
||
this._prepareMaskMatB.needsUpdate = true
|
||
this._prepareMaskSpriteMatB.colorNode = maskColorB
|
||
this._prepareMaskSpriteMatB.needsUpdate = true
|
||
|
||
// ── Copy ──────────────────────────────────────────────────────────────────
|
||
this._copyMatA.fragmentNode = this._maskTexA
|
||
this._copyMatA.needsUpdate = true
|
||
this._copyMatB.fragmentNode = this._maskTexB
|
||
this._copyMatB.needsUpdate = true
|
||
|
||
// ── Edge detection ────────────────────────────────────────────────────────
|
||
const buildEdgeDetect = (maskDownTex: any) =>
|
||
Fn(() => {
|
||
const resolution = textureSize(maskDownTex)
|
||
const invSize = vec2(1).div(resolution).toVar()
|
||
const uvOffset = vec4(1.0, 0.0, 0.0, 1.0).mul(vec4(invSize, invSize))
|
||
const uvNode = uv()
|
||
const c1 = maskDownTex.sample(uvNode.add(uvOffset.xy)).toVar()
|
||
const c2 = maskDownTex.sample(uvNode.sub(uvOffset.xy)).toVar()
|
||
const c3 = maskDownTex.sample(uvNode.add(uvOffset.yw)).toVar()
|
||
const c4 = maskDownTex.sample(uvNode.sub(uvOffset.yw)).toVar()
|
||
const diff1 = mul(c1.r.sub(c2.r), 0.5)
|
||
const diff2 = mul(c3.r.sub(c4.r), 0.5)
|
||
const d = vec2(diff1, diff2).length()
|
||
const a1 = min(c1.g, c2.g)
|
||
const a2 = min(c3.g, c4.g)
|
||
const visibilityFactor = min(a1, a2)
|
||
// R = visible edge, G = hidden edge (matches OutlineNode convention)
|
||
const edgeColor = visibilityFactor
|
||
.oneMinus()
|
||
.greaterThan(0.001)
|
||
.select(vec3(1, 0, 0), vec3(0, 1, 0))
|
||
return vec4(edgeColor, 1).mul(d)
|
||
})()
|
||
|
||
this._edgeDetectMatA.fragmentNode = buildEdgeDetect(this._maskDownTexA)
|
||
this._edgeDetectMatA.needsUpdate = true
|
||
this._edgeDetectMatB.fragmentNode = buildEdgeDetect(this._maskDownTexB)
|
||
this._edgeDetectMatB.needsUpdate = true
|
||
|
||
// ── Separable blur ────────────────────────────────────────────────────────
|
||
const MAX_RADIUS = 4
|
||
|
||
const gaussianPdf = Fn(([x, sigma]: any[]) =>
|
||
float(0.398_94).mul(exp(float(-0.5).mul(x).mul(x).div(sigma.mul(sigma))).div(sigma)),
|
||
)
|
||
|
||
const buildBlur = (maskDownTex: any, blurColorTex: any, blurDir: any, kernelRadius: any) =>
|
||
Fn(() => {
|
||
const resolution = textureSize(maskDownTex)
|
||
const invSize = vec2(1).div(resolution).toVar()
|
||
const uvNode = uv()
|
||
const sigma = kernelRadius.div(2).toVar()
|
||
const weightSum = gaussianPdf(0, sigma).toVar()
|
||
const diffuseSum = blurColorTex.sample(uvNode).mul(weightSum).toVar()
|
||
const delta = blurDir.mul(invSize).mul(kernelRadius).div(MAX_RADIUS).toVar()
|
||
const uvOffset = delta.toVar()
|
||
Loop(
|
||
{ start: int(1), end: int(MAX_RADIUS), type: 'int', condition: '<=' },
|
||
({ i }: any) => {
|
||
const x = kernelRadius.mul(float(i)).div(MAX_RADIUS)
|
||
const w = gaussianPdf(x, sigma)
|
||
diffuseSum.addAssign(
|
||
blurColorTex
|
||
.sample(uvNode.add(uvOffset))
|
||
.add(blurColorTex.sample(uvNode.sub(uvOffset)))
|
||
.mul(w),
|
||
)
|
||
weightSum.addAssign(w.mul(2))
|
||
uvOffset.addAssign(delta)
|
||
},
|
||
)
|
||
return diffuseSum.div(weightSum)
|
||
})()
|
||
|
||
this._blurMat1A.fragmentNode = buildBlur(
|
||
this._maskDownTexA,
|
||
this._blurColorTexA,
|
||
this._blurDirectionA,
|
||
this.primaryEdgeThicknessNode,
|
||
)
|
||
this._blurMat1A.needsUpdate = true
|
||
this._blurMat2A.fragmentNode = buildBlur(
|
||
this._maskDownTexA,
|
||
this._blurColorTexA,
|
||
this._blurDirectionA,
|
||
float(MAX_RADIUS),
|
||
)
|
||
this._blurMat2A.needsUpdate = true
|
||
this._blurMat1B.fragmentNode = buildBlur(
|
||
this._maskDownTexB,
|
||
this._blurColorTexB,
|
||
this._blurDirectionB,
|
||
this.secondaryEdgeThicknessNode,
|
||
)
|
||
this._blurMat1B.needsUpdate = true
|
||
this._blurMat2B.fragmentNode = buildBlur(
|
||
this._maskDownTexB,
|
||
this._blurColorTexB,
|
||
this._blurDirectionB,
|
||
float(MAX_RADIUS),
|
||
)
|
||
this._blurMat2B.needsUpdate = true
|
||
|
||
// ── Composite ─────────────────────────────────────────────────────────────
|
||
const buildComposite = (maskTex: any, edge1Tex: any, edge2Tex: any, edgeGlowNode: any) =>
|
||
Fn(() => maskTex.r.mul(edge1Tex.add(edge2Tex.mul(edgeGlowNode))))()
|
||
|
||
this._compositeMatA.fragmentNode = buildComposite(
|
||
this._maskTexA,
|
||
this._edge1TexA,
|
||
this._edge2TexA,
|
||
this.primaryEdgeGlowNode,
|
||
)
|
||
this._compositeMatA.needsUpdate = true
|
||
this._compositeMatB.fragmentNode = buildComposite(
|
||
this._maskTexB,
|
||
this._edge1TexB,
|
||
this._edge2TexB,
|
||
this.secondaryEdgeGlowNode,
|
||
)
|
||
this._compositeMatB.needsUpdate = true
|
||
|
||
return this._textureNodeA
|
||
}
|
||
|
||
dispose() {
|
||
this.primaryObjects.length = 0
|
||
this.secondaryObjects.length = 0
|
||
this._depthRT.dispose()
|
||
this._groupA.dispose()
|
||
this._groupB.dispose()
|
||
this._depthMaterial.dispose()
|
||
this._depthSpriteMaterial.dispose()
|
||
this._prepareMaskMatA.dispose()
|
||
this._prepareMaskSpriteMatA.dispose()
|
||
this._copyMatA.dispose()
|
||
this._edgeDetectMatA.dispose()
|
||
this._blurMat1A.dispose()
|
||
this._blurMat2A.dispose()
|
||
this._compositeMatA.dispose()
|
||
this._prepareMaskMatB.dispose()
|
||
this._prepareMaskSpriteMatB.dispose()
|
||
this._copyMatB.dispose()
|
||
this._edgeDetectMatB.dispose()
|
||
this._blurMat1B.dispose()
|
||
this._blurMat2B.dispose()
|
||
this._compositeMatB.dispose()
|
||
}
|
||
|
||
private _buildCache(objects: Object3D[], cache: Set<Object3D>) {
|
||
for (const obj of objects) {
|
||
if (!obj || !obj.traverse) continue
|
||
try {
|
||
obj.traverse((child: any) => {
|
||
if (child.isMesh || child.isSprite) cache.add(child)
|
||
})
|
||
} catch {
|
||
// Skip objects that were disposed or removed from the scene graph
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
export const mergedOutline = (
|
||
scene: any,
|
||
camera: any,
|
||
params?: ConstructorParameters<typeof MergedOutlineNode>[2],
|
||
) => new MergedOutlineNode(scene, camera, params)
|