* Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fixed conflict * editor: per-door-type floor-plan symbols Render a distinct, static plan symbol for each door type in the registry floor-plan builder (`packages/nodes/src/door/floorplan.ts`), independent of the door's live open/close animation: - single / hinged: fixed 90° swing with a dashed quarter-circle arc - double / french: two mirrored half-width leaves + dashed arcs - folding / bifold: static zigzag accordion (~80% span) on the wall face - sliding: bypass — two overlapping panels on parallel tracks + arrow - pocket: thin white leaf, ~60% closed, sliding into the solid wall - barn: surface-mounted panel parked over the wall, dashed closed-ghost + slide arrow The swing arc is dashed in screen-pixel units (the renderer uses non-scaling-stroke). Symbols are oriented by hingesSide / swingDirection / slideDirection as appropriate. Also includes pre-existing working-tree changes unrelated to the door symbols: group move/rotate transform and box-select tweaks, and a regenerated ifc-converter next-env.d.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix recessed ceiling fixtures and draw safety * feat(editor): magnetic wall-snap with per-kind beacon (2D + 3D) Snap the wall draft / endpoint-move point onto existing wall geometry — corners, midpoints, wall–wall intersections, and along-wall edges — and show a beacon at the snap point whose glyph encodes what it caught (square = corner, triangle = midpoint, ✕ = intersection, circle = edge). - Pure snap geometry extracted to wall-snap-geometry.ts (unit-tested). - Ephemeral useWallSnapIndicator store drives a 3D pillar+glyph beacon and a 2D SVG glyph beacon, both indigo to match the alignment guides. - Gated by a new persisted "Magnetic snap" toggle in the Display menu (useEditor); honored by draw + commit + endpoint-move in both views. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * editor: garage and open-doorway floor-plan symbols Extend the per-door-type plan symbols in the registry floor-plan builder (packages/nodes/src/door/floorplan.ts): - open doorway (openingKind === 'opening'): bare gap, no leaf/arc/panel (mirrors the 3D system, which renders only the cutout for openings) - garage sectional: closed leaf + side tracks into the garage + dashed parked ghost at the inner end - garage roll-up: closed leaf + coil barrel (capsule) with a coil hint - garage tilt-up: closed leaf + dashed parked panel + dashed curved up-and-over swing path - gate the swing arc to actual swing doors (hinged/double/french) so other types fall back to the plain footprint Garage mechanisms sit on the interior (door-local -z) side to match the 3D garage builders, independent of swingDirection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * arch: enforce layer boundaries — registry dispatch, store relocation, shared helper Three architectural fixes to bring the branch into full compliance: 1. **ceiling-system kind check → CeilingCutCapability** Replace `child.type === 'item'` branch in `ceiling-system` with registry dispatch. Add `CeilingCutCapability` type to `packages/core` registry types, implement `buildCeilingHole` on `itemDefinition`, and rewrite `collectRecessedItemHoles` → `collectCeilingHoles` to dispatch through `nodeRegistry` — viewer never again inspects a node's kind directly. 2. **useAlignmentGuides + useWallSnapIndicator → packages/editor** These stores are editor-only UI (snap beacons, alignment guides). Move them from `packages/core/src/store/` to `packages/editor/src/store/`, re-export from `packages/editor`, and update all 34 consumer files across `packages/editor` and `packages/nodes` to import from `@pascal-app/editor`. 3. **findLevelAncestorId extracted to core** `item-light-system` had a private `resolveNodeLevelId` that duplicated level-ancestor traversal logic. Extract it as `findLevelAncestorId` in `packages/core` (spatial-grid-sync), export it, and replace the local copy. All four packages typecheck cleanly (zero errors). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: fix lint, untrack .claude/launch.json Run bun check --write to clear 8 Biome errors (formatting + import order + one unused import). Untrack .claude/launch.json and add it plus .claude/settings.local.json to .gitignore so local IDE/agent configs stop landing in commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
641 lines
23 KiB
TypeScript
641 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'
|
||
import { hasDrawableGeometry } from './drawable-geometry'
|
||
|
||
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) => {
|
||
if (!hasDrawableGeometry(geo)) return
|
||
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 (!hasDrawableGeometry(geo)) return
|
||
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 (!hasDrawableGeometry(geo)) return
|
||
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) {
|
||
obj.traverse((child: any) => {
|
||
if (child.isMesh || child.isSprite) cache.add(child)
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
export const mergedOutline = (
|
||
scene: any,
|
||
camera: any,
|
||
params?: ConstructorParameters<typeof MergedOutlineNode>[2],
|
||
) => new MergedOutlineNode(scene, camera, params)
|