From 9f62bd108fa6156aec6289cc40733ef219c18a6a Mon Sep 17 00:00:00 2001 From: Pascal Date: Thu, 26 Mar 2026 15:51:37 -0400 Subject: [PATCH] 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 --- .../components/tools/ceiling/ceiling-tool.tsx | 2 + .../src/components/tools/roof/roof-tool.tsx | 2 + .../src/components/tools/slab/slab-tool.tsx | 2 + .../src/components/tools/wall/wall-tool.tsx | 8 +- .../components/ui/controls/slider-control.tsx | 280 ++++++------------ .../src/components/ui/panels/window-panel.tsx | 22 +- packages/editor/src/hooks/use-keyboard.ts | 24 +- .../viewer/src/components/viewer/index.tsx | 6 +- .../src/components/viewer/post-processing.tsx | 142 ++++----- 9 files changed, 197 insertions(+), 291 deletions(-) diff --git a/packages/editor/src/components/tools/ceiling/ceiling-tool.tsx b/packages/editor/src/components/tools/ceiling/ceiling-tool.tsx index fe4d91e7..3cfb1c8b 100644 --- a/packages/editor/src/components/tools/ceiling/ceiling-tool.tsx +++ b/packages/editor/src/components/tools/ceiling/ceiling-tool.tsx @@ -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([]) } diff --git a/packages/editor/src/components/tools/roof/roof-tool.tsx b/packages/editor/src/components/tools/roof/roof-tool.tsx index 4ebb9d85..cb1a77e9 100644 --- a/packages/editor/src/components/tools/roof/roof-tool.tsx +++ b/packages/editor/src/components/tools/roof/roof-tool.tsx @@ -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 })) diff --git a/packages/editor/src/components/tools/slab/slab-tool.tsx b/packages/editor/src/components/tools/slab/slab-tool.tsx index 85a58de9..0ce67007 100644 --- a/packages/editor/src/components/tools/slab/slab-tool.tsx +++ b/packages/editor/src/components/tools/slab/slab-tool.tsx @@ -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([]) } diff --git a/packages/editor/src/components/tools/wall/wall-tool.tsx b/packages/editor/src/components/tools/wall/wall-tool.tsx index c5fa0f33..7dd467be 100644 --- a/packages/editor/src/components/tools/wall/wall-tool.tsx +++ b/packages/editor/src/components/tools/wall/wall-tool.tsx @@ -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 } diff --git a/packages/editor/src/components/ui/controls/slider-control.tsx b/packages/editor/src/components/ui/controls/slider-control.tsx index d4e18f82..92d7e77c 100644 --- a/packages/editor/src/components/ui/controls/slider-control.tsx +++ b/packages/editor/src/components/ui/controls/slider-control.tsx @@ -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(null) - const [dragMin, setDragMin] = useState(null) - const [dragMax, setDragMax] = useState(null) - - const trackRef = useRef(null) - const containerRef = useRef(null) - + const dragRef = useRef<{ startX: number; startValue: number } | null>(null) + const labelRef = useRef(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) => { 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) => { + 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) => { + 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) => { - 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) => { 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 (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} - ref={containerRef} > - {/* Reset button that appears when dragged away from start */} - {isDragging && dragStartValue !== null && dragStartValue !== value && ( - - )} - -
{label}
- + {/* Label — drag handle */}
- {/* Track dots background */} -
- {[...Array(9)].map((_, i) => ( -
- ))} -
- - {/* Original Thumb Ghost */} - {isDragging && startPercent !== null && ( -
- )} - - {/* Active Thumb */} + {/* Grip dots — 2×3 grid */}
+ > + {[...Array(6)].map((_, i) => ( +
+ ))} +
+ {label}
-
+
+ + {/* Value — click to edit */} +
{isEditing ? ( -
+ <> setInputValue(e.target.value)} onKeyDown={handleInputKeyDown} type="text" value={inputValue} /> {unit && {unit}} -
+ ) : (
diff --git a/packages/editor/src/components/ui/panels/window-panel.tsx b/packages/editor/src/components/ui/panels/window-panel.tsx index 54974bcb..ddce225d 100644 --- a/packages/editor/src/components/ui/panels/window-panel.tsx +++ b/packages/editor/src/components/ui/panels/window-panel.tsx @@ -209,8 +209,6 @@ export function WindowPanel() { Xpos } - 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() { Ypos } - 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() { handleUpdate({ width: v })} precision={2} step={0.1} @@ -254,8 +249,7 @@ export function WindowPanel() { /> handleUpdate({ height: v })} precision={2} step={0.1} @@ -267,8 +261,7 @@ export function WindowPanel() { handleUpdate({ frameThickness: v })} precision={3} step={0.01} @@ -277,8 +270,7 @@ export function WindowPanel() { /> handleUpdate({ frameDepth: v })} precision={3} step={0.01} @@ -390,8 +382,7 @@ export function WindowPanel() {
handleUpdate({ sillDepth: v })} precision={3} step={0.01} @@ -400,8 +391,7 @@ export function WindowPanel() { /> handleUpdate({ sillThickness: v })} precision={3} step={0.01} diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index c2abffff..0ae879e0 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -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') diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 572c1805..eafbb26f 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -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 = ({ const renderer = new THREE.WebGPURenderer(props as any) renderer.toneMapping = THREE.ACESFilmicToneMapping renderer.toneMappingExposure = 0.9 + // renderer.init() // Only use when using return renderer }} + resize={{ + debounce: 100, + }} shadows={{ type: THREE.PCFShadowMap, enabled: true, }} > {/* */} - {/* { 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 + + 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