fix: slider rewrite, ESC key fix, antialias improvements (#187)

Slider control:
- Rewrite drag interaction to use label-based drag instead of track slider
- Remove hardcoded min/max bounds, default to unbounded
- Add ArrowLeft/ArrowRight key support alongside Up/Down
- Cleaner, more compact UI (238 lines vs 332)

ESC key cancel fix:
- Pressing ESC during mid-action (drawing wall, placing slab) now only
  cancels the current action, not also switching back to select mode
- Tools mark cancel as consumed via markToolCancelConsumed()
- Second ESC press switches to select mode as before

Window panel:
- Remove hardcoded min/max on position, dimension, and frame sliders
- Works with new unbounded slider defaults

Viewer:
- Re-enable default antialias on WebGPU renderer
- Remove GroundOccluder (no longer needed)
- Add resize debounce (100ms)
- Clean up post-processing pipeline code
This commit is contained in:
Pascal
2026-03-26 15:51:37 -04:00
committed by GitHub
parent d287428599
commit 9f62bd108f
9 changed files with 197 additions and 291 deletions
@@ -3,6 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
import { mix, positionLocal } from 'three/tsl'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere'
@@ -183,6 +184,7 @@ export const CeilingTool: React.FC = () => {
}
const onCancel = () => {
if (points.length > 0) markToolCancelConsumed()
setPoints([])
}
@@ -13,6 +13,7 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three'
import { BufferGeometry, DoubleSide, type Group, type Line, Vector3 } from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
@@ -232,6 +233,7 @@ export const RoofTool: React.FC = () => {
const onCancel = () => {
if (corner1Ref.current) {
markToolCancelConsumed()
corner1Ref.current = null
outlineRef.current.visible = false
setPreview((prev) => ({ ...prev, corner1: null }))
@@ -2,6 +2,7 @@ import { emitter, type GridEvent, type LevelNode, SlabNode, useScene } from '@pa
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere'
@@ -150,6 +151,7 @@ export const SlabTool: React.FC = () => {
}
const onCancel = () => {
if (points.length > 0) markToolCancelConsumed()
setPoints([])
}
@@ -2,10 +2,11 @@ import { emitter, type GridEvent, type LevelNode, useScene, type WallNode } from
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere'
import { createWallOnCurrentLevel, snapWallDraftPoint, WALL_MIN_LENGTH, type WallPlanPoint } from './wall-drafting'
import { createWallOnCurrentLevel, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting'
const WALL_HEIGHT = 2.5
@@ -17,7 +18,7 @@ const updateWallPreview = (mesh: Mesh, start: Vector3, end: Vector3) => {
const direction = new Vector3(end.x - start.x, 0, end.z - start.z)
const length = direction.length()
if (length < WALL_MIN_LENGTH) {
if (length < 0.01) {
mesh.visible = false
return
}
@@ -142,7 +143,7 @@ export const WallTool: React.FC = () => {
endingPoint.current.set(snappedEnd[0], event.position[1], snappedEnd[1])
const dx = endingPoint.current.x - startingPoint.current.x
const dz = endingPoint.current.z - startingPoint.current.z
if (dx * dx + dz * dz < WALL_MIN_LENGTH * WALL_MIN_LENGTH) return
if (dx * dx + dz * dz < 0.01 * 0.01) return
createWallOnCurrentLevel(
[startingPoint.current.x, startingPoint.current.z],
[endingPoint.current.x, endingPoint.current.z],
@@ -166,6 +167,7 @@ export const WallTool: React.FC = () => {
const onCancel = () => {
if (buildingState.current === 1) {
markToolCancelConsumed()
buildingState.current = 0
wallPreviewRef.current.visible = false
}
@@ -20,8 +20,8 @@ export function SliderControl({
label,
value,
onChange,
min = 0,
max = 100,
min = Number.NEGATIVE_INFINITY,
max = Number.POSITIVE_INFINITY,
precision = 0,
step = 1,
className,
@@ -32,23 +32,12 @@ export function SliderControl({
const [isHovered, setIsHovered] = useState(false)
const [inputValue, setInputValue] = useState(value.toFixed(precision))
// Track the original value and bounds when dragging starts
const [dragStartValue, setDragStartValue] = useState<number | null>(null)
const [dragMin, setDragMin] = useState<number | null>(null)
const [dragMax, setDragMax] = useState<number | null>(null)
const trackRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const dragRef = useRef<{ startX: number; startValue: number } | null>(null)
const labelRef = useRef<HTMLDivElement>(null)
const valueRef = useRef(value)
valueRef.current = value
const clamp = useCallback(
(val: number) => {
return Math.min(Math.max(val, min), max)
},
[min, max],
)
const clamp = useCallback((val: number) => Math.min(Math.max(val, min), max), [min, max])
useEffect(() => {
if (!isEditing) {
@@ -56,123 +45,91 @@ export function SliderControl({
}
}, [value, precision, isEditing])
// Wheel support on the label
useEffect(() => {
const container = containerRef.current
if (!container) return
const el = labelRef.current
if (!el) return
const handleWheel = (e: WheelEvent) => {
if (isEditing) return
e.preventDefault()
const direction = e.deltaY < 0 ? 1 : -1
let scrollStep = step
if (e.shiftKey) scrollStep = step * 10
else if (e.altKey) scrollStep = step * 0.1
const newValue = clamp(valueRef.current + direction * scrollStep)
const finalValue = Number.parseFloat(newValue.toFixed(precision))
if (finalValue !== valueRef.current) {
onChange(finalValue)
}
let s = step
if (e.shiftKey) s = step * 10
else if (e.altKey) s = step * 0.1
const newValue = clamp(valueRef.current + direction * s)
const final = Number.parseFloat(newValue.toFixed(precision))
if (final !== valueRef.current) onChange(final)
}
container.addEventListener('wheel', handleWheel, { passive: false })
return () => container.removeEventListener('wheel', handleWheel)
el.addEventListener('wheel', handleWheel, { passive: false })
return () => el.removeEventListener('wheel', handleWheel)
}, [isEditing, step, clamp, onChange, precision])
// Arrow key support while hovered
useEffect(() => {
if (!isHovered || isEditing) return
const handleKeyDown = (e: KeyboardEvent) => {
let direction = 0
if (e.key === 'ArrowUp') direction = 1
else if (e.key === 'ArrowDown') direction = -1
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') direction = 1
else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') direction = -1
if (direction !== 0) {
e.preventDefault()
let scrollStep = step
if (e.shiftKey) scrollStep = step * 10
else if (e.altKey) scrollStep = step * 0.1
const newValue = clamp(valueRef.current + direction * scrollStep)
const finalValue = Number.parseFloat(newValue.toFixed(precision))
if (finalValue !== valueRef.current) {
onChange(finalValue)
}
let s = step
if (e.shiftKey) s = step * 10
else if (e.metaKey || e.ctrlKey) s = step * 0.1
const newValue = clamp(valueRef.current + direction * s)
const final = Number.parseFloat(newValue.toFixed(precision))
if (final !== valueRef.current) onChange(final)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isHovered, isEditing, step, clamp, onChange, precision])
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
const handleLabelPointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (isEditing) return
e.preventDefault()
const track = trackRef.current
if (!track) return
e.currentTarget.setPointerCapture(e.pointerId)
dragRef.current = { startX: e.clientX, startValue: valueRef.current }
setIsDragging(true)
setDragStartValue(value)
setDragMin(min)
setDragMax(max)
useScene.temporal.getState().pause()
const rect = track.getBoundingClientRect()
const updateValueFromEvent = (clientX: number) => {
const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
const rawValue = min + percent * (max - min)
// snap to step
const snapped = Math.round(rawValue / step) * step
const finalValue = Number.parseFloat(clamp(snapped).toFixed(precision))
onChange(finalValue)
}
updateValueFromEvent(e.clientX)
const handlePointerMove = (moveEvent: PointerEvent) => {
updateValueFromEvent(moveEvent.clientX)
}
const handlePointerUp = (e: PointerEvent) => {
// Only stop dragging if we didn't release on the reset button
// Let the reset button's onPointerDown handle its own cleanup
if ((e.target as HTMLElement).closest('button')) {
return
}
setIsDragging(false)
const startVal = dragStartValue
const finalVal = valueRef.current
setDragStartValue(null)
setDragMin(null)
setDragMax(null)
document.removeEventListener('pointermove', handlePointerMove)
document.removeEventListener('pointerup', handlePointerUp)
if (startVal !== null && startVal !== finalVal) {
// Revert to start value while paused so the undo baseline is clean
onChange(startVal)
useScene.temporal.getState().resume()
// Apply final value while recording
onChange(finalVal)
} else {
useScene.temporal.getState().resume()
}
}
document.addEventListener('pointermove', handlePointerMove)
document.addEventListener('pointerup', handlePointerUp)
},
[isEditing, min, max, step, precision, clamp, onChange, dragStartValue, value],
[isEditing],
)
const handleLabelPointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return
const { startX, startValue } = dragRef.current
const dx = e.clientX - startX
let s = step
if (e.shiftKey) s = step * 10
else if (e.metaKey || e.ctrlKey) s = step * 0.1
// 4 px per step at default sensitivity
const newValue = clamp(Number.parseFloat((startValue + (dx / 4) * s).toFixed(precision)))
onChange(newValue)
},
[step, precision, clamp, onChange],
)
const handleLabelPointerUp = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return
const { startValue } = dragRef.current
const finalVal = valueRef.current
dragRef.current = null
setIsDragging(false)
e.currentTarget.releasePointerCapture(e.pointerId)
if (startValue !== finalVal) {
onChange(startValue)
useScene.temporal.getState().resume()
onChange(finalVal)
} else {
useScene.temporal.getState().resume()
}
},
[onChange],
)
const handleValueClick = useCallback(() => {
@@ -180,10 +137,6 @@ export function SliderControl({
setInputValue(value.toFixed(precision))
}, [value, precision])
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value)
}, [])
const submitValue = useCallback(() => {
const numValue = Number.parseFloat(inputValue)
if (Number.isNaN(numValue)) {
@@ -194,10 +147,6 @@ export function SliderControl({
setIsEditing(false)
}, [inputValue, onChange, clamp, precision, value])
const handleInputBlur = useCallback(() => {
submitValue()
}, [submitValue])
const handleInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
@@ -220,104 +169,61 @@ export function SliderControl({
[submitValue, value, precision, step, clamp, onChange],
)
const currentMin = isDragging && dragMin !== null ? dragMin : min
const currentMax = isDragging && dragMax !== null ? dragMax : max
const percent = Math.max(
0,
Math.min(100, ((value - currentMin) / (currentMax - currentMin)) * 100),
)
const startPercent =
dragStartValue !== null
? Math.max(
0,
Math.min(100, ((dragStartValue - currentMin) / (currentMax - currentMin)) * 100),
)
: null
return (
<div
className={cn(
'group relative flex h-12 w-full items-center rounded-lg border border-border/50 px-3 text-sm transition-colors',
isDragging ? 'bg-[#3e3e3e]' : 'bg-[#2C2C2E] hover:bg-[#3e3e3e]',
'group flex h-7 w-full select-none items-center rounded-lg px-2 transition-colors',
isDragging ? 'bg-white/5' : 'hover:bg-white/5',
className,
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
ref={containerRef}
>
{/* Reset button that appears when dragged away from start */}
{isDragging && dragStartValue !== null && dragStartValue !== value && (
<button
className="pointer-events-auto absolute -top-10 right-0 z-50 cursor-pointer rounded-md bg-[#2C2C2E] px-2 py-1 font-medium text-[10px] text-muted-foreground shadow-sm ring-1 ring-border/50 hover:bg-[#3e3e3e] hover:text-foreground"
onPointerDown={(e) => {
e.stopPropagation()
onChange(dragStartValue)
setDragStartValue(null)
setDragMin(null)
setDragMax(null)
setIsDragging(false)
useScene.temporal.getState().resume()
}}
>
Reset
</button>
)}
<div className="w-[80px] shrink-0 select-none truncate text-muted-foreground">{label}</div>
{/* Label — drag handle */}
<div
className={cn(
'relative mx-2 flex h-full flex-1 touch-none items-center justify-center',
isDragging ? 'cursor-grabbing' : 'cursor-grab',
'flex shrink-0 cursor-ew-resize items-center gap-1.5 text-xs transition-colors',
isDragging ? 'text-foreground' : 'text-muted-foreground hover:text-foreground/80',
)}
onPointerDown={handlePointerDown}
ref={trackRef}
onPointerDown={handleLabelPointerDown}
onPointerMove={handleLabelPointerMove}
onPointerUp={handleLabelPointerUp}
ref={labelRef}
>
{/* Track dots background */}
<div className="pointer-events-none absolute inset-x-0 flex items-center justify-between px-1 opacity-30">
{[...Array(9)].map((_, i) => (
<div className="h-[3px] w-[3px] rounded-full bg-current" key={i} />
))}
</div>
{/* Original Thumb Ghost */}
{isDragging && startPercent !== null && (
<div
className="pointer-events-none absolute top-1/2 h-6 w-[3px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-foreground/20 shadow-sm"
style={{ left: `${startPercent}%` }}
/>
)}
{/* Active Thumb */}
{/* Grip dots — 2×3 grid */}
<div
className={cn(
'pointer-events-none absolute top-1/2 h-6 w-[3px] -translate-x-1/2 -translate-y-1/2 rounded-full shadow-sm transition',
isDragging
? 'scale-y-110 bg-foreground'
: 'bg-foreground/60 group-hover:bg-foreground/80',
'grid grid-cols-2 gap-[2.5px] transition-opacity',
isDragging ? 'opacity-70' : 'opacity-25 group-hover:opacity-50',
)}
style={{ left: `${percent}%` }}
/>
>
{[...Array(6)].map((_, i) => (
<div className="h-[2px] w-[2px] rounded-full bg-current" key={i} />
))}
</div>
<span className="font-medium">{label}</span>
</div>
<div className="flex w-[50px] shrink-0 justify-end">
<div className="flex-1" />
{/* Value — click to edit */}
<div className="flex items-center text-xs">
{isEditing ? (
<div className="flex items-center">
<>
<input
autoFocus
className="w-full bg-transparent p-0 text-right font-mono text-foreground outline-none selection:bg-primary/30"
onBlur={handleInputBlur}
onChange={handleInputChange}
className="w-14 bg-transparent p-0 text-right font-mono text-foreground outline-none selection:bg-primary/30"
onBlur={submitValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleInputKeyDown}
type="text"
value={inputValue}
/>
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
</div>
</>
) : (
<div
className="flex w-full cursor-text items-center justify-end text-foreground/60 transition-colors hover:text-foreground"
className="flex cursor-text items-center text-foreground/60 transition-colors hover:text-foreground"
onClick={handleValueClick}
>
<span className="font-mono tabular-nums tracking-tight">
@@ -209,8 +209,6 @@ export function WindowPanel() {
X<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
</>
}
max={10}
min={-10}
onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
precision={2}
step={0.1}
@@ -223,8 +221,6 @@ export function WindowPanel() {
Y<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
</>
}
max={10}
min={-10}
onChange={(v) => handleUpdate({ position: [node.position[0], v, node.position[2]] })}
precision={2}
step={0.1}
@@ -244,8 +240,7 @@ export function WindowPanel() {
<PanelSection title="Dimensions">
<SliderControl
label="Width"
max={5}
min={0.2}
min={0}
onChange={(v) => handleUpdate({ width: v })}
precision={2}
step={0.1}
@@ -254,8 +249,7 @@ export function WindowPanel() {
/>
<SliderControl
label="Height"
max={5}
min={0.2}
min={0}
onChange={(v) => handleUpdate({ height: v })}
precision={2}
step={0.1}
@@ -267,8 +261,7 @@ export function WindowPanel() {
<PanelSection title="Frame">
<SliderControl
label="Thickness"
max={0.2}
min={0.01}
min={0}
onChange={(v) => handleUpdate({ frameThickness: v })}
precision={3}
step={0.01}
@@ -277,8 +270,7 @@ export function WindowPanel() {
/>
<SliderControl
label="Depth"
max={0.3}
min={0.01}
min={0}
onChange={(v) => handleUpdate({ frameDepth: v })}
precision={3}
step={0.01}
@@ -390,8 +382,7 @@ export function WindowPanel() {
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Depth"
max={0.5}
min={0.01}
min={0}
onChange={(v) => handleUpdate({ sillDepth: v })}
precision={3}
step={0.01}
@@ -400,8 +391,7 @@ export function WindowPanel() {
/>
<SliderControl
label="Thickness"
max={0.2}
min={0.005}
min={0}
onChange={(v) => handleUpdate({ sillThickness: v })}
precision={3}
step={0.01}
+18 -6
View File
@@ -4,6 +4,13 @@ import { useEffect } from 'react'
import { sfxEmitter } from '../lib/sfx-bus'
import useEditor from '../store/use-editor'
// Tools call this in their onCancel handler when they have an active mid-action to cancel,
// so that the global Escape handler knows not to also switch to select mode.
let _toolCancelConsumed = false
export const markToolCancelConsumed = () => {
_toolCancelConsumed = true
}
export const useKeyboard = () => {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -14,15 +21,20 @@ export const useKeyboard = () => {
if (e.key === 'Escape') {
e.preventDefault()
_toolCancelConsumed = false
emitter.emit('tool:cancel')
// Return to the default select tool while keeping the active building/level context.
useEditor.getState().setEditingHole(null)
useEditor.getState().setMode('select')
// Only switch to select mode if no tool had an active mid-action to cancel.
// (e.g. mid-wall draw or mid-slab polygon should only cancel the action, not exit the tool)
if (!_toolCancelConsumed) {
// Return to the default select tool while keeping the active building/level context.
useEditor.getState().setEditingHole(null)
useEditor.getState().setMode('select')
// Clear selections to close UI panels, but KEEP the active building and level context.
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
useEditor.getState().setSelectedReferenceId(null)
// Clear selections to close UI panels, but KEEP the active building and level context.
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
useEditor.getState().setSelectedReferenceId(null)
}
} else if (e.key === '1' && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
useEditor.getState().setPhase('site')
@@ -21,7 +21,6 @@ import { ScanSystem } from '../../systems/scan/scan-system'
import { WallCutout } from '../../systems/wall/wall-cutout'
import { ZoneSystem } from '../../systems/zone/zone-system'
import { SceneRenderer } from '../renderers/scene-renderer'
import { GroundOccluder } from './ground-occluder'
import { Lights } from './lights'
import { PerfMonitor } from './perf-monitor'
import PostProcessing from './post-processing'
@@ -110,15 +109,18 @@ const Viewer: React.FC<ViewerProps> = ({
const renderer = new THREE.WebGPURenderer(props as any)
renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = 0.9
// renderer.init() // Only use when using <DebugRenderer />
return renderer
}}
resize={{
debounce: 100,
}}
shadows={{
type: THREE.PCFShadowMap,
enabled: true,
}}
>
{/* <AnimatedBackground isDark={theme === 'dark'} /> */}
<GroundOccluder />
<ViewerCamera />
{/* <directionalLight position={[10, 10, 5]} intensity={0.5} castShadow
@@ -3,7 +3,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Color, Layers, UnsignedByteType } from 'three'
import { outline } from 'three/addons/tsl/display/OutlineNode.js'
import { ssgi } from 'three/addons/tsl/display/SSGINode.js'
import { traa } from 'three/addons/tsl/display/TRAANode.js'
import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js'
import {
add,
@@ -21,7 +20,6 @@ import {
time,
uniform,
vec4,
velocity,
} from 'three/tsl'
import { RenderPipeline, type WebGPURenderer } from 'three/webgpu'
import { SCENE_LAYER, ZONE_LAYER } from '../../lib/layers'
@@ -129,67 +127,11 @@ const PostProcessingPasses = () => {
outliner.hoveredObjects.length = 0
try {
// Scene pass with MRT for SSGI
const scenePass = pass(scene, camera)
scenePass.setMRT(
mrt({
output,
diffuseColor,
normal: directionToColor(normalView),
velocity,
}),
)
// Get texture outputs
const scenePassColor = scenePass.getTextureNode('output')
const scenePassDiffuse = scenePass.getTextureNode('diffuseColor')
const scenePassDepth = scenePass.getTextureNode('depth')
const scenePassNormal = scenePass.getTextureNode('normal')
const scenePassVelocity = scenePass.getTextureNode('velocity')
// Optimize texture bandwidth
const diffuseTexture = scenePass.getTexture('diffuseColor')
diffuseTexture.type = UnsignedByteType
const normalTexture = scenePass.getTexture('normal')
normalTexture.type = UnsignedByteType
// Extract normal from color-encoded texture
const sceneNormal = sample((uv) => {
return colorToDirection(scenePassNormal.sample(uv))
})
const zonePass = pass(scene, camera)
zonePass.setLayers(zoneLayers)
// SSGI Pass (cast to PerspectiveCamera for SSGI)
const giPass = ssgi(scenePassColor, scenePassDepth, sceneNormal, camera as any)
giPass.sliceCount.value = SSGI_PARAMS.sliceCount
giPass.stepCount.value = SSGI_PARAMS.stepCount
giPass.radius.value = SSGI_PARAMS.radius
giPass.expFactor.value = SSGI_PARAMS.expFactor
giPass.thickness.value = SSGI_PARAMS.thickness
giPass.backfaceLighting.value = SSGI_PARAMS.backfaceLighting
giPass.aoIntensity.value = SSGI_PARAMS.aoIntensity
giPass.giIntensity.value = SSGI_PARAMS.giIntensity
giPass.useLinearThickness.value = SSGI_PARAMS.useLinearThickness
giPass.useScreenSpaceSampling.value = SSGI_PARAMS.useScreenSpaceSampling
giPass.useTemporalFiltering = SSGI_PARAMS.useTemporalFiltering
const giTexture = (giPass as any).getTextureNode()
// DenoiseNode only denoises RGB — alpha is passed through unchanged.
// SSGI packs AO into alpha, so we remap it into RGB before denoising.
// convertToTexture() inside denoise() will call rtt() on this vec4 node automatically.
const aoAsRgb = vec4(giTexture.a, giTexture.a, giTexture.a, float(1))
const denoisePass = denoise(aoAsRgb, scenePassDepth, sceneNormal, camera)
denoisePass.index.value = 0
denoisePass.radius.value = 4
const gi = giPass.rgb
const ao = (denoisePass as any).r
// const gi = giPass.rgb;
// const ao = giPass.a;
const scenePassColor = scenePass.getTextureNode('output')
// Background detection via alpha: renderer clears with alpha=0 (setClearAlpha(0) in useFrame),
// so background pixels have scenePassColor.a=0 while geometry pixels have output.a=1.
@@ -198,11 +140,62 @@ const PostProcessingPasses = () => {
const hasGeometry = scenePassColor.a
const contentAlpha = hasGeometry.max(zonePass.a)
// Composite: scene * AO + diffuse * GI
const compositePass = vec4(
add(scenePassColor.rgb.mul(ao), add(zonePass.rgb, scenePassDiffuse.rgb.mul(gi))),
contentAlpha,
)
let sceneColor = scenePassColor as unknown as ReturnType<typeof vec4>
if (SSGI_PARAMS.enabled) {
// MRT only needed for SSGI (diffuse for GI, normal for SSGI sampling)
scenePass.setMRT(
mrt({
output,
diffuseColor,
normal: directionToColor(normalView),
}),
)
const scenePassDiffuse = scenePass.getTextureNode('diffuseColor')
const scenePassDepth = scenePass.getTextureNode('depth')
const scenePassNormal = scenePass.getTextureNode('normal')
// Optimize texture bandwidth
const diffuseTexture = scenePass.getTexture('diffuseColor')
diffuseTexture.type = UnsignedByteType
const normalTexture = scenePass.getTexture('normal')
normalTexture.type = UnsignedByteType
// Extract normal from color-encoded texture
const sceneNormal = sample((uv) => colorToDirection(scenePassNormal.sample(uv)))
const giPass = ssgi(scenePassColor, scenePassDepth, sceneNormal, camera as any)
giPass.sliceCount.value = SSGI_PARAMS.sliceCount
giPass.stepCount.value = SSGI_PARAMS.stepCount
giPass.radius.value = SSGI_PARAMS.radius
giPass.expFactor.value = SSGI_PARAMS.expFactor
giPass.thickness.value = SSGI_PARAMS.thickness
giPass.backfaceLighting.value = SSGI_PARAMS.backfaceLighting
giPass.aoIntensity.value = SSGI_PARAMS.aoIntensity
giPass.giIntensity.value = SSGI_PARAMS.giIntensity
giPass.useLinearThickness.value = SSGI_PARAMS.useLinearThickness
giPass.useScreenSpaceSampling.value = SSGI_PARAMS.useScreenSpaceSampling
giPass.useTemporalFiltering = SSGI_PARAMS.useTemporalFiltering
const giTexture = (giPass as any).getTextureNode()
// DenoiseNode only denoises RGB — alpha is passed through unchanged.
// SSGI packs AO into alpha, so we remap it into RGB before denoising.
const aoAsRgb = vec4(giTexture.a, giTexture.a, giTexture.a, float(1))
const denoisePass = denoise(aoAsRgb, scenePassDepth, sceneNormal, camera)
denoisePass.index.value = 0
denoisePass.radius.value = 4
const gi = giPass.rgb
const ao = (denoisePass as any).r
// Composite: scene * AO + diffuse * GI
sceneColor = vec4(
add(scenePassColor.rgb.mul(ao), add(zonePass.rgb, scenePassDiffuse.rgb.mul(gi))),
contentAlpha,
)
}
function generateSelectedOutlinePass() {
const edgeStrength = uniform(3)
@@ -256,20 +249,15 @@ const PostProcessingPasses = () => {
const selectedOutlinePass = generateSelectedOutlinePass()
const hoverOutlinePass = generateHoverOutlinePass()
// Combine composite with outlines BEFORE applying TRAA
const compositeWithOutlines = SSGI_PARAMS.enabled
? vec4(add(compositePass.rgb, selectedOutlinePass.add(hoverOutlinePass)), compositePass.a)
: vec4(add(scenePassColor.rgb, selectedOutlinePass.add(hoverOutlinePass)), scenePassColor.a)
const compositeWithOutlines = vec4(
add(sceneColor.rgb, selectedOutlinePass.add(hoverOutlinePass)),
sceneColor.a,
)
// TRAA (Temporal Reprojection Anti-Aliasing) - applied AFTER combining everything
const traaOutput = traa(compositeWithOutlines, scenePassDepth, scenePassVelocity, camera)
// For zone-over-background pixels, scenePassDepth=1.0 (no scene geometry) causes TRAA
// to output black. Use hasGeometry to blend: geometry pixels use traaRgb, all others
// (zones over background, pure background) use compositePass.rgb directly.
const traaRgb = (traaOutput as any).rgb
const colorSource = mix(compositePass.rgb, traaRgb, hasGeometry)
const finalOutput = vec4(mix(bgUniform.current, colorSource, contentAlpha), float(1))
const finalOutput = vec4(
mix(bgUniform.current, compositeWithOutlines.rgb, contentAlpha),
float(1),
)
const renderPipeline = new RenderPipeline(renderer as unknown as WebGPURenderer)
renderPipeline.outputNode = finalOutput