From c4ee1af01d82d37fce44f4372ebfc4bf82b9ce7a Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 9 Jul 2026 18:07:53 -0400 Subject: [PATCH 01/16] feat(editor): dev-only window hook for deterministic camera poses Exposes a getter for the CameraControls impl in development so screenshot/automation tooling can set exact camera poses. Co-Authored-By: Claude Fable 5 --- .../components/editor/custom-camera-controls.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 3fdec0ed2..67de8a063 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -391,6 +391,20 @@ export const CustomCameraControls = () => { raycaster.layers.enable(ZONE_LAYER) }, [camera, raycaster]) + useEffect(() => { + // Dev-only: deterministic camera poses for screenshot/automation tooling. + // A getter, not a snapshot — drei recreates the impl when the default + // camera changes, so a captured instance goes stale. + if (process.env.NODE_ENV !== 'development') return + const w = window as typeof window & { + __pascalCameraControls?: (() => CameraControlsImpl | null) | null + } + w.__pascalCameraControls = () => controls.current + return () => { + w.__pascalCameraControls = null + } + }, []) + useEffect(() => { if (isPreviewMode || isFirstPersonMode || isRestoringFirstPersonPose()) return let targetY = 0 From c51092a28c0c1d8bb8839e74fd3484faef456326 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 9 Jul 2026 18:27:50 -0400 Subject: [PATCH 02/16] =?UTF-8?q?feat(viewer):=20rendering=20pass=20?= =?UTF-8?q?=E2=80=94=20sun-dominant=20lighting,=20grade,=20gradient-sky=20?= =?UTF-8?q?IBL,=20albedo=20clamp,=20SSGI=20tune?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Shadow intensity clamp 0.55 → 0.9 (sun no longer leaks into shadow), 2048 shadow maps, PCFSoft filtering - Scene-referred contrast/saturation grade before ACES output (GRADE_PARAMS in post-processing) - Procedural gradient-sky IBL (cool zenith / warm horizon / ground bounce) replaces the venice_sunset HDR fetch; env-only, background unchanged - Near-white albedos clamped to ~0.83 linear (defaults, white palette, catalog preset-white/softwhite, schema presets) - SSGI: 2 slices / 6 steps, radius 1.6, aoIntensity 1.7, giIntensity 2 (bounce on); studio hemi 0.6→0.45, fill 0.75→0.6 Co-Authored-By: Claude Fable 5 --- packages/core/src/material-library.ts | 10 +- packages/core/src/schema/material.ts | 8 +- .../viewer/src/components/viewer/index.tsx | 2 +- .../viewer/src/components/viewer/lights.tsx | 9 +- .../src/components/viewer/post-processing.tsx | 31 +++++- .../components/viewer/scene-environment.tsx | 94 +++++++++++++++---- packages/viewer/src/lib/materials.ts | 22 +++-- packages/viewer/src/lib/scene-themes.ts | 12 +-- .../viewer/src/systems/wall/wall-materials.ts | 2 +- 9 files changed, 135 insertions(+), 55 deletions(-) diff --git a/packages/core/src/material-library.ts b/packages/core/src/material-library.ts index ca1ab1b93..da289baae 100644 --- a/packages/core/src/material-library.ts +++ b/packages/core/src/material-library.ts @@ -1948,11 +1948,13 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ label: 'White', category: 'colors', description: 'Clean painted finish', - previewColor: '#ffffff', + // Real white paint reflects ~80%; a literal #ffffff albedo kills GI/shadow + // contrast, so "white" is clamped to ≈0.83 linear. + previewColor: '#e9e9e9', preset: { maps: {}, mapProperties: { - color: '#ffffff', + color: '#e9e9e9', roughness: 0.9, metalness: 0, repeatX: 1, @@ -1980,11 +1982,11 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ label: 'Soft White', category: 'colors', description: 'Warm off-white painted finish', - previewColor: '#f2eee6', + previewColor: '#ebe7df', preset: { maps: {}, mapProperties: { - color: '#f2eee6', + color: '#ebe7df', roughness: 0.9, metalness: 0, repeatX: 1, diff --git a/packages/core/src/schema/material.ts b/packages/core/src/schema/material.ts index 0c8fcae5a..24e10a786 100644 --- a/packages/core/src/schema/material.ts +++ b/packages/core/src/schema/material.ts @@ -114,7 +114,7 @@ export type MaterialPresetPayload = z.infer export const DEFAULT_MATERIALS: Record = { white: { - color: '#ffffff', + color: '#e9e9e9', roughness: 0.9, metalness: 0, opacity: 1, @@ -162,7 +162,7 @@ export const DEFAULT_MATERIALS: Record = { side: 'front', }, plaster: { - color: '#f5f5dc', + color: '#ebebd3', roughness: 0.95, metalness: 0, opacity: 1, @@ -178,7 +178,7 @@ export const DEFAULT_MATERIALS: Record = { side: 'front', }, marble: { - color: '#fafafa', + color: '#ebebeb', roughness: 0.2, metalness: 0.1, opacity: 1, @@ -186,7 +186,7 @@ export const DEFAULT_MATERIALS: Record = { side: 'front', }, custom: { - color: '#ffffff', + color: '#e9e9e9', roughness: 0.5, metalness: 0, opacity: 1, diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 0153b087e..dd9388faf 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -519,7 +519,7 @@ const Viewer = forwardRef(function Viewer( debounce: 100, }} shadows={{ - type: THREE.PCFShadowMap, + type: THREE.PCFSoftShadowMap, enabled: true, }} > diff --git a/packages/viewer/src/components/viewer/lights.tsx b/packages/viewer/src/components/viewer/lights.tsx index 27dbf6c45..29ddc2c9e 100644 --- a/packages/viewer/src/components/viewer/lights.tsx +++ b/packages/viewer/src/components/viewer/lights.tsx @@ -23,9 +23,10 @@ const SHADOWS_DISABLED = ).has('shadows') // Shadow darkness for the bright key lights (themes drive most lights past -// intensity 1). The aesthetic prototype runs these near-black (≈1.0); this is a -// deliberate middle ground — present, but not the heavy contact shadow there. -const MAX_SHADOW_INTENSITY = 0.55 +// intensity 1). Runs high so shadowed areas actually lose the sun's +// contribution — the ambient/hemisphere/IBL stack provides the fill. The old +// 0.55 clamp leaked 45% of the key light into shadow and flattened interiors. +const MAX_SHADOW_INTENSITY = 0.9 // Shadow frustum framing. The frustum is fit to the BUILDING geometry (not the // camera): we union the bounds of all registered scene nodes, fit a sphere, and @@ -246,7 +247,7 @@ export function Lights() { lightRefs.current[index] = ref }} shadow-bias={-0.002} - shadow-mapSize={[1024, 1024]} + shadow-mapSize={[2048, 2048]} shadow-normalBias={0.3} shadow-radius={1.5} > diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index f8f957e6b..6188ea199 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -18,8 +18,10 @@ import { premultiplyAlpha, renderOutput, sample, + saturation, time, uniform, + vec3, vec4, } from 'three/tsl' import { RenderPipeline, type WebGPURenderer } from 'three/webgpu' @@ -31,17 +33,25 @@ import { mergedOutline } from '../../lib/merged-outline-node' import { getSceneTheme } from '../../lib/scene-themes' import useViewer from '../../store/use-viewer' +// Scene-referred grade applied before the output tone mapping (AgX). AgX rolls +// highlights off gently but reads flat on its own; a mild mid-gray-pivot +// contrast + saturation lift restores the punch. Rendered shading only. +export const GRADE_PARAMS = { + contrast: 1.05, + saturation: 1.1, +} + // SSGI Parameters - adjust these to fine-tune global illumination and ambient occlusion export const SSGI_PARAMS = { enabled: true, - sliceCount: 1, - stepCount: 4, - radius: 1, + sliceCount: 2, + stepCount: 6, + radius: 1.6, expFactor: 1.5, thickness: 0.5, backfaceLighting: 0.5, - aoIntensity: 1.5, - giIntensity: 0, + aoIntensity: 1.7, + giIntensity: 2, useLinearThickness: false, useScreenSpaceSampling: true, useTemporalFiltering: false, @@ -443,6 +453,17 @@ const PostProcessingPasses = ({ ) } + // Scene-referred grade (contrast around mid-gray + saturation) before the + // pipeline's output tone mapping. Kept out of solid/schematic shading so + // the flat presets stay exact. + if (shading === 'rendered') { + const gradedRgb = saturation( + sceneColor.rgb.div(0.18).pow(vec3(GRADE_PARAMS.contrast)).mul(0.18), + GRADE_PARAMS.saturation, + ) + sceneColor = vec4(gradedRgb, sceneColor.a) + } + // Single merged outline node: one shared depth pass for both selected + hovered groups. const outliner = useViewer.getState().outliner let compositeWithOutlines = sceneColor diff --git a/packages/viewer/src/components/viewer/scene-environment.tsx b/packages/viewer/src/components/viewer/scene-environment.tsx index c17df4442..d01b99194 100644 --- a/packages/viewer/src/components/viewer/scene-environment.tsx +++ b/packages/viewer/src/components/viewer/scene-environment.tsx @@ -1,27 +1,81 @@ 'use client' -import { Environment } from '@react-three/drei' -import { Suspense } from 'react' +import { useThree } from '@react-three/fiber' +import { useEffect, useMemo } from 'react' +import * as THREE from 'three/webgpu' /** - * Scene IBL — drei's prefiltered environment map, exported as an opt-in - * *child* rather than baked into the Viewer component, so embed / - * thumbnail surfaces that don't want the HDRI fetch simply don't mount it. - * This is what gives PBR metals their reflections and lifts the lighting on - * vertical surfaces (walls), which flat directional + hemisphere lights can't - * do alone. Intensity is dialled below the preset default so it complements - * the scene lights rather than washing them out. Only visible in `rendered` - * shading. - * - * The HDR is self-hosted (drei's `preset="sunset"` resolves to the same - * `venice_sunset_1k.hdr` on raw.githack.com, which intermittently fails). - * Every app that mounts this — like `/audios/sfx` — must ship the file in its - * own `public/hdri/`. + * Scene IBL — a small procedural gradient sky (cool zenith → warm horizon → + * dim ground bounce) used purely as the environment light source. The visible + * backdrop stays the flat theme background (composited in post-processing); + * this texture is never shown. Replaces the old `venice_sunset_1k.hdr` fetch: + * no network dependency, and the vertical color split means upward-facing + * surfaces read cooler than vertical ones instead of everything getting the + * same directionless warm wash. Exported as an opt-in child so + * embed / thumbnail surfaces that don't want IBL simply don't mount it. + * Only affects `rendered` shading (Lambert ignores env maps). */ + +// Linear-space gradient stops. +const ZENITH = [0.4, 0.56, 0.78] as const +const HORIZON = [0.95, 0.84, 0.66] as const +const GROUND = [0.38, 0.35, 0.3] as const + +const ENV_INTENSITY = 0.6 +const WIDTH = 64 +const HEIGHT = 32 + +function buildGradientSky(): THREE.DataTexture { + const data = new Float32Array(WIDTH * HEIGHT * 4) + for (let y = 0; y < HEIGHT; y++) { + // Row 0 = v0 = nadir, top row = zenith (equirect v spans -90°..+90°). + const lat = ((y + 0.5) / HEIGHT) * 2 - 1 + let r: number + let g: number + let b: number + if (lat <= 0) { + // Below the horizon: flat ground bounce, slightly darker toward nadir. + const k = 1 + lat * 0.35 + r = GROUND[0] * k + g = GROUND[1] * k + b = GROUND[2] * k + } else { + // pow < 1 widens the warm horizon band. + const t = lat ** 0.65 + r = HORIZON[0] + (ZENITH[0] - HORIZON[0]) * t + g = HORIZON[1] + (ZENITH[1] - HORIZON[1]) * t + b = HORIZON[2] + (ZENITH[2] - HORIZON[2]) * t + } + for (let x = 0; x < WIDTH; x++) { + const i = (y * WIDTH + x) * 4 + data[i] = r + data[i + 1] = g + data[i + 2] = b + data[i + 3] = 1 + } + } + const texture = new THREE.DataTexture(data, WIDTH, HEIGHT, THREE.RGBAFormat, THREE.FloatType) + texture.mapping = THREE.EquirectangularReflectionMapping + texture.colorSpace = THREE.LinearSRGBColorSpace + texture.needsUpdate = true + return texture +} + export function SceneEnvironment() { - return ( - - - - ) + const scene = useThree((state) => state.scene) + const texture = useMemo(buildGradientSky, []) + + useEffect(() => { + const prevEnvironment = scene.environment + const prevIntensity = scene.environmentIntensity + scene.environment = texture + scene.environmentIntensity = ENV_INTENSITY + return () => { + scene.environment = prevEnvironment + scene.environmentIntensity = prevIntensity + texture.dispose() + } + }, [scene, texture]) + + return null } diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index 677b1d13f..27ade98f5 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -30,14 +30,16 @@ export const CLAY_PALETTE: Record = { furnishing: '#d2ccbe', } +// Albedos are clamped to ≈0.83 linear (max channel #eb): real white paint +// reflects ~80%, and pure-white albedo kills GI/shadow contrast. export const WHITE_PALETTE: Record = { - wall: '#f4f3ef', - floor: '#ece9e2', - ceiling: '#fbfaf6', + wall: '#ebeae6', + floor: '#e7e4dd', + ceiling: '#ebeae6', roof: '#dedbd2', - joinery: '#e8e5dc', + joinery: '#e5e2d9', glazing: '#dbe8ee', - furnishing: '#efede7', + furnishing: '#e9e7e1', } export const MONO_PALETTE: Record = { @@ -614,11 +616,11 @@ export function createSurfaceRoleMaterial( } export function baseMaterial(shading: RenderShading = 'rendered'): THREE.Material { - return cachedDefaultMaterial('base', '#f2f0ed', 0.5, shading) + return cachedDefaultMaterial('base', '#e9e7e3', 0.5, shading) } export function DEFAULT_WALL_MATERIAL(shading: RenderShading = 'rendered'): THREE.Material { - return cachedDefaultMaterial('wall', '#ffffff', 0.9, shading) + return cachedDefaultMaterial('wall', '#e9e6e0', 0.9, shading) } export function DEFAULT_SLAB_MATERIAL(shading: RenderShading = 'rendered'): THREE.Material { @@ -658,7 +660,7 @@ export function DEFAULT_WINDOW_MATERIAL(shading: RenderShading = 'rendered'): TH } export function DEFAULT_CEILING_MATERIAL(shading: RenderShading = 'rendered'): THREE.Material { - return cachedDefaultMaterial('ceiling', '#f5f5dc', 0.95, shading) + return cachedDefaultMaterial('ceiling', '#ebebd3', 0.95, shading) } export function DEFAULT_ROOF_MATERIAL(shading: RenderShading = 'rendered'): THREE.Material { @@ -666,11 +668,11 @@ export function DEFAULT_ROOF_MATERIAL(shading: RenderShading = 'rendered'): THRE } export function DEFAULT_SHELF_MATERIAL(shading: RenderShading = 'rendered'): THREE.Material { - return cachedDefaultMaterial('shelf', '#ffffff', 0.9, shading) + return cachedDefaultMaterial('shelf', '#e9e6e0', 0.9, shading) } export function DEFAULT_STAIR_MATERIAL(shading: RenderShading = 'rendered'): THREE.Material { - return cachedDefaultMaterial('stair', '#ffffff', 0.9, shading) + return cachedDefaultMaterial('stair', '#e9e6e0', 0.9, shading) } export function disposeMaterial(material: THREE.Material): void { diff --git a/packages/viewer/src/lib/scene-themes.ts b/packages/viewer/src/lib/scene-themes.ts index f544c7386..139650a58 100644 --- a/packages/viewer/src/lib/scene-themes.ts +++ b/packages/viewer/src/lib/scene-themes.ts @@ -32,10 +32,10 @@ export const SCENE_THEMES: SceneTheme[] = [ background: '#ffffff', ground: '#f4f4f2', ambient: { color: '#ffffff', intensity: 0.15 }, - hemi: { sky: '#ffffff', ground: '#aaa49a', intensity: 0.6 }, + hemi: { sky: '#ffffff', ground: '#aaa49a', intensity: 0.45 }, lights: [ { position: [10, 10, 10], color: '#ffffff', intensity: 4, castShadow: true }, - { position: [-10, 10, -10], color: '#ffffff', intensity: 0.75 }, + { position: [-10, 10, -10], color: '#ffffff', intensity: 0.6 }, ], toneMappingExposure: 0.9, clayTints: { @@ -97,7 +97,7 @@ export const SCENE_THEMES: SceneTheme[] = [ ambient: { color: '#eef0ef', intensity: 1.1 }, hemi: { sky: '#f4f5f3', ground: '#bcbfbb', intensity: 0.9 }, lights: [{ position: [12, 28, 10], color: '#f4f5f3', intensity: 0.8, castShadow: true }], - toneMappingExposure: 0.95, + toneMappingExposure: 0.9, clayTints: { wall: '#dedfdc', floor: '#cdcec9', @@ -118,7 +118,7 @@ export const SCENE_THEMES: SceneTheme[] = [ { position: [16, 24, 12], color: '#e6efff', intensity: 1.8, castShadow: true }, { position: [-12, 10, -8], color: '#9fb6d8', intensity: 0.4 }, ], - toneMappingExposure: 0.95, + toneMappingExposure: 0.9, clayTints: { wall: '#9fb6d2', floor: '#8ba2c2', @@ -160,7 +160,7 @@ export const SCENE_THEMES: SceneTheme[] = [ { position: [-14, 22, -10], color: '#a4b6e8', intensity: 1.4, castShadow: true }, { position: [14, 6, 8], color: '#ffb070', intensity: 0.9 }, ], - toneMappingExposure: 1.1, + toneMappingExposure: 0.9, clayTints: { wall: '#c5b9cf', floor: '#ad9fbb', @@ -202,7 +202,7 @@ export const SCENE_THEMES: SceneTheme[] = [ { position: [16, 22, 12], color: '#fff6d8', intensity: 3, castShadow: true }, { position: [-12, 10, -8], color: '#bfe0c2', intensity: 0.5 }, ], - toneMappingExposure: 0.95, + toneMappingExposure: 0.9, clayTints: { wall: '#eef0e6', floor: '#d8ddc6', diff --git a/packages/viewer/src/systems/wall/wall-materials.ts b/packages/viewer/src/systems/wall/wall-materials.ts index f8d2da256..cb4eea3de 100644 --- a/packages/viewer/src/systems/wall/wall-materials.ts +++ b/packages/viewer/src/systems/wall/wall-materials.ts @@ -28,7 +28,7 @@ import { type SceneMaterials = Record | undefined -const DEFAULT_WALL_COLOR = '#f2f0ed' +const DEFAULT_WALL_COLOR = '#e9e7e3' const WALL_HIGHLIGHT_PROFILES = { delete: { From c48a37c30a736b8f66b3a41188845271b978f3f7 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 9 Jul 2026 18:41:58 -0400 Subject: [PATCH 03/16] feat(viewer): shadow-caster-only cutaway, glass fresnel, ground fade, specular-map unwiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SHADOW_ONLY_LAYER (4) + lib/shadow-only.ts: hidden roofs/levels in level-solo (editor) and dollhouse (GLB viewer) stay in the shadow map, so interiors keep sun shadows + window light patches; only the sun's shadow camera enables the layer - Glass: fresnel-driven opacity + envMapIntensity on transparent standard materials (catalog glass, scene glass, window default) - Site ground: radial fade into the theme background at the lot boundary (TSL colorNode); dead ground-occluder.tsx removed - Catalog: 22 bogus *specular*→metalnessMap wirings removed (specular level maps are not metalness; they darkened/metallized dielectrics) Co-Authored-By: Claude Fable 5 --- packages/core/src/material-library.ts | 24 ----- packages/nodes/src/site/renderer.tsx | 36 ++++++- .../src/components/viewer/glb-scene.tsx | 18 +++- .../src/components/viewer/ground-occluder.tsx | 95 ------------------- .../viewer/src/components/viewer/lights.tsx | 4 + packages/viewer/src/lib/layers.ts | 11 +++ packages/viewer/src/lib/materials.ts | 30 ++++++ packages/viewer/src/lib/scene-themes.ts | 2 +- packages/viewer/src/lib/shadow-only.ts | 42 ++++++++ .../viewer/src/systems/level/level-system.tsx | 21 +++- wiki/architecture/layers.md | 1 + 11 files changed, 158 insertions(+), 126 deletions(-) delete mode 100644 packages/viewer/src/components/viewer/ground-occluder.tsx create mode 100644 packages/viewer/src/lib/shadow-only.ts diff --git a/packages/core/src/material-library.ts b/packages/core/src/material-library.ts index da289baae..602739e28 100644 --- a/packages/core/src/material-library.ts +++ b/packages/core/src/material-library.ts @@ -132,7 +132,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ maps: { albedoMap: '/material/wood/floor_plank_1/floor_plank-diffuse.webp', aoMap: '/material/wood/floor_plank_1/floor_plank-ao.webp', - metalnessMap: '/material/wood/floor_plank_1/floor_plank-specular.webp', normalMap: '/material/wood/floor_plank_1/floor_plank-normal.webp', }, mapProperties: { @@ -169,7 +168,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ preset: { maps: { albedoMap: '/material/wood/hungarian_parquet_10/Hungarian Parquet_10_baseColor.webp', - metalnessMap: '/material/wood/hungarian_parquet_10/Hungarian Parquet_10_specularLevel.webp', normalMap: '/material/wood/hungarian_parquet_10/Hungarian Parquet_10_normal.webp', roughnessMap: '/material/wood/hungarian_parquet_10/Hungarian Parquet_10_roughness.webp', }, @@ -207,7 +205,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ preset: { maps: { albedoMap: '/material/wood/hungarian_parquet_2/Hungarian Parquet_2_baseColor.webp', - metalnessMap: '/material/wood/hungarian_parquet_2/Hungarian Parquet_2_specularLevel.webp', normalMap: '/material/wood/hungarian_parquet_2/Hungarian Parquet_2_normal.webp', roughnessMap: '/material/wood/hungarian_parquet_2/Hungarian Parquet_2_roughness.webp', }, @@ -246,8 +243,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ preset: { maps: { albedoMap: '/material/wood/square_parquet_21/Square Pattern Parquet_21_baseColor.webp', - metalnessMap: - '/material/wood/square_parquet_21/Square Pattern Parquet_21_specularLevel.webp', normalMap: '/material/wood/square_parquet_21/Square Pattern Parquet_21_normal.webp', roughnessMap: '/material/wood/square_parquet_21/Square Pattern Parquet_21_roughness.webp', }, @@ -286,8 +281,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ preset: { maps: { albedoMap: '/material/wood/square_wood_parquet_23/Square Pattern Parquet_23_baseColor.webp', - metalnessMap: - '/material/wood/square_wood_parquet_23/Square Pattern Parquet_23_specularLevel.webp', normalMap: '/material/wood/square_wood_parquet_23/Square Pattern Parquet_23_normal.webp', roughnessMap: '/material/wood/square_wood_parquet_23/Square Pattern Parquet_23_roughness.webp', @@ -327,7 +320,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ maps: { albedoMap: '/material/wood/wood_fine/wood_fine_1-diffuse.webp', aoMap: '/material/wood/wood_fine/wood_fine_1-ao.webp', - metalnessMap: '/material/wood/wood_fine/wood_fine_1-specular.webp', normalMap: '/material/wood/wood_fine/wood_fine_1-normal.webp', }, mapProperties: { @@ -365,7 +357,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ maps: { albedoMap: '/material/wood/wood_fine_11/wood_fine_11-diffuse.webp', aoMap: '/material/wood/wood_fine_11/wood_fine_11-ao.webp', - metalnessMap: '/material/wood/wood_fine_11/wood_fine_11-specular.webp', normalMap: '/material/wood/wood_fine_11/wood_fine_11-normal.webp', }, mapProperties: { @@ -403,7 +394,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ maps: { albedoMap: '/material/wood/wood_fine_13/wood_fine_13-diffuse.webp', aoMap: '/material/wood/wood_fine_13/wood_fine_13-ao.webp', - metalnessMap: '/material/wood/wood_fine_13/wood_fine_13-specular.webp', normalMap: '/material/wood/wood_fine_13/wood_fine_13-normal.webp', }, mapProperties: { @@ -441,7 +431,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ maps: { albedoMap: '/material/wood/wood_fine_2/wood_fine_2-diffuse.webp', aoMap: '/material/wood/wood_fine_2/wood_fine_2-ao.webp', - metalnessMap: '/material/wood/wood_fine_2/wood_fine_2-specular.webp', normalMap: '/material/wood/wood_fine_2/wood_fine_2-normal.webp', }, mapProperties: { @@ -479,7 +468,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ maps: { albedoMap: '/material/wood/wood_fine_22/wood_fine_22-diffuse.webp', aoMap: '/material/wood/wood_fine_22/wood_fine_22-ao.webp', - metalnessMap: '/material/wood/wood_fine_22/wood_fine_22-specular.webp', normalMap: '/material/wood/wood_fine_22/wood_fine_22-normal.webp', }, mapProperties: { @@ -517,7 +505,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ maps: { albedoMap: '/material/wood/wood_fine_24/wood_fine_24-diffuse.webp', aoMap: '/material/wood/wood_fine_24/wood_fine_24-ao.webp', - metalnessMap: '/material/wood/wood_fine_24/wood_fine_24-specular.webp', normalMap: '/material/wood/wood_fine_24/wood_fine_24-normal.webp', }, mapProperties: { @@ -593,7 +580,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ preset: { maps: { albedoMap: '/material/wood/wooden_parquet_11/Classic Parquet_11_baseColor.webp', - metalnessMap: '/material/wood/wooden_parquet_11/Classic Parquet_11_specularLevel.webp', normalMap: '/material/wood/wooden_parquet_11/Classic Parquet_11_normal.webp', roughnessMap: '/material/wood/wooden_parquet_11/Classic Parquet_11_roughness.webp', }, @@ -1018,7 +1004,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ maps: { albedoMap: '/material/flooring/garage_panel/garage_panel_diffuse.jpg', aoMap: '/material/flooring/garage_panel/garage_panel_ao.jpg', - metalnessMap: '/material/flooring/garage_panel/garage_panel_specular.jpg', normalMap: '/material/flooring/garage_panel/garage_panel_normal.jpg', }, mapProperties: { @@ -1056,7 +1041,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ maps: { albedoMap: '/material/flooring/green_labradorite/green_labradorite_diffuse.jpg', aoMap: '/material/flooring/green_labradorite/green_labradorite_ao.jpg', - metalnessMap: '/material/flooring/green_labradorite/green_labradorite_specular.jpg', normalMap: '/material/flooring/green_labradorite/green_labradorite_normal.jpg', }, mapProperties: { @@ -1133,7 +1117,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ maps: { albedoMap: '/material/flooring/pool_tiles/pool_tiles_diffuse.jpg', aoMap: '/material/flooring/pool_tiles/pool_tiles_ao.jpg', - metalnessMap: '/material/flooring/pool_tiles/pool_tiles_specular.jpg', normalMap: '/material/flooring/pool_tiles/pool_tiles_normal.jpg', }, mapProperties: { @@ -1171,7 +1154,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ maps: { albedoMap: '/material/flooring/tiles_checker/tiles_checker_diffuse.jpg', aoMap: '/material/flooring/tiles_checker/tiles_checker_ao.jpg', - metalnessMap: '/material/flooring/tiles_checker/tiles_checker_specular.jpg', normalMap: '/material/flooring/tiles_checker/tiles_checker_normal.jpg', }, mapProperties: { @@ -1209,7 +1191,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ maps: { albedoMap: '/material/flooring/tiles_grid/tiles_grid_diffuse.jpg', aoMap: '/material/flooring/tiles_grid/tiles_grid_ao.jpg', - metalnessMap: '/material/flooring/tiles_grid/tiles_grid_specular.jpg', normalMap: '/material/flooring/tiles_grid/tiles_grid_normal.jpg', }, mapProperties: { @@ -1247,7 +1228,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ maps: { albedoMap: '/material/flooring/stone_wall/stone_wall_diffuse.webp', aoMap: '/material/flooring/stone_wall/stone_wall_ao.webp', - metalnessMap: '/material/flooring/stone_wall/stone_wall_specular.webp', normalMap: '/material/flooring/stone_wall/stone_wall_normal.webp', }, mapProperties: { @@ -1285,7 +1265,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ maps: { albedoMap: '/material/flooring/wooden_ceramic_3/wooden_ceramic-diffuse.webp', aoMap: '/material/flooring/wooden_ceramic_3/wooden_ceramic-ao.webp', - metalnessMap: '/material/flooring/wooden_ceramic_3/wooden_ceramic-specular.webp', normalMap: '/material/flooring/wooden_ceramic_3/wooden_ceramic-normal.webp', }, mapProperties: { @@ -1477,7 +1456,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ maps: { albedoMap: '/material/flooring/green_glass_quartzite/green_glass_quartzite_diffuse.jpg', aoMap: '/material/flooring/green_glass_quartzite/green_glass_quartzite_ao.jpg', - metalnessMap: '/material/flooring/green_glass_quartzite/green_glass_quartzite_specular.jpg', normalMap: '/material/flooring/green_glass_quartzite/green_glass_quartzite_normal.jpg', }, mapProperties: { @@ -1594,7 +1572,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ maps: { albedoMap: '/material/flooring/statuaretto/statuaretto_diffuse.jpg', aoMap: '/material/flooring/statuaretto/statuaretto_ao.jpg', - metalnessMap: '/material/flooring/statuaretto/statuaretto_specular.jpg', normalMap: '/material/flooring/statuaretto/statuaretto_normal.jpg', }, mapProperties: { @@ -1710,7 +1687,6 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ maps: { albedoMap: '/material/flooring/wooden_ceramic_2/wooden_ceramic-diffuse.webp', aoMap: '/material/flooring/wooden_ceramic_2/wooden_ceramic-ao.webp', - metalnessMap: '/material/flooring/wooden_ceramic_2/wooden_ceramic-specular.webp', normalMap: '/material/flooring/wooden_ceramic_2/wooden_ceramic-normal.webp', }, mapProperties: { diff --git a/packages/nodes/src/site/renderer.tsx b/packages/nodes/src/site/renderer.tsx index 87d66dc12..bea5dc2b7 100644 --- a/packages/nodes/src/site/renderer.tsx +++ b/packages/nodes/src/site/renderer.tsx @@ -24,6 +24,7 @@ import { Shape, ShapeGeometry, } from 'three' +import { color, float, mix, positionWorld, smoothstep, vec2 } from 'three/tsl' import { MeshLambertNodeMaterial } from 'three/webgpu' const Y_OFFSET = 0.01 @@ -59,21 +60,54 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { useRegistry(node.id, 'site', ref) const bgColor = useViewer((state) => getSceneTheme(state.sceneTheme).ground) + const backgroundColor = useViewer((state) => getSceneTheme(state.sceneTheme).background) const livePolygon = useLiveNodeOverrides( (state) => (state.overrides.get(node.id)?.polygon as SiteNode['polygon'] | undefined) ?? null, ) const polygonPoints = livePolygon?.points ?? node.polygon?.points + // Centroid + radius of the lot polygon, for the presentation fade below. + const fadeBounds = useMemo(() => { + if (!polygonPoints || polygonPoints.length < 3) return null + let cx = 0 + let cz = 0 + for (const [x, z] of polygonPoints) { + cx += x ?? 0 + cz += z ?? 0 + } + cx /= polygonPoints.length + cz /= polygonPoints.length + let radius = 0 + for (const [x, z] of polygonPoints) { + radius = Math.max(radius, Math.hypot((x ?? 0) - cx, (z ?? 0) - cz)) + } + return { cx, cz, radius } + }, [polygonPoints]) + // Lit (not Basic) so the site ground receives the directional shadow — Basic // is unlit, which is why shadows used to stop dead at the slab edge. polygonOffset // keeps it tucked behind the grid/slab as before. + // + // The ground fill fades radially into the theme background toward the lot + // boundary so the scene reads as a deliberate presentation vignette instead + // of a hard-edged plate floating on the backdrop. const groundMaterial = useMemo(() => { const material = new MeshLambertNodeMaterial({ color: bgColor }) + if (fadeBounds) { + const center = vec2(fadeBounds.cx, fadeBounds.cz) + const dist = positionWorld.xz.sub(center).length() + const fade = smoothstep( + float(fadeBounds.radius * 0.45), + float(fadeBounds.radius * 0.98), + dist, + ) + material.colorNode = mix(color(bgColor), color(backgroundColor), fade) + } material.polygonOffset = true material.polygonOffsetFactor = 1 material.polygonOffsetUnits = 1 return material - }, [bgColor]) + }, [bgColor, backgroundColor, fadeBounds]) // Cache slab polygon references to keep the selector stable across unrelated store updates const slabPolygonsCache = useRef<[number, number][][]>([]) diff --git a/packages/viewer/src/components/viewer/glb-scene.tsx b/packages/viewer/src/components/viewer/glb-scene.tsx index 72ffa64be..f0b2a19c0 100644 --- a/packages/viewer/src/components/viewer/glb-scene.tsx +++ b/packages/viewer/src/components/viewer/glb-scene.tsx @@ -12,6 +12,7 @@ import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three- import { useGLTFKTX2 } from '../../hooks/use-gltf-ktx2' import { ZONE_LAYER } from '../../lib/layers' import { createSurfaceRoleMaterial } from '../../lib/materials' +import { applyShadowOnly, clearShadowOnly } from '../../lib/shadow-only' import useViewer from '../../store/use-viewer' import { GlbInteractive, type GlbInteractiveItem } from './glb-interactive' import { GlbReferenceNodes } from './glb-reference-nodes' @@ -598,8 +599,12 @@ export function GlbScene({ // Snap (not lerp) in walkthrough so the first-person collider, built from // these world positions, matches the stacked building immediately. node.position.y = walkthroughMode ? targetY : lerp(node.position.y, targetY, delta * 12) - node.visible = - walkthroughMode || levelMode !== 'solo' || !selectedLevel || id === selectedLevel + // Solo keeps hidden levels casting shadows (shadow-caster-only layers). + const hidden = + !walkthroughMode && levelMode === 'solo' && Boolean(selectedLevel) && id !== selectedLevel + if (hidden) applyShadowOnly(node) + else clearShadowOnly(node) + node.visible = true }) }, 5) @@ -819,7 +824,12 @@ export function GlbScene({ // focused level actually has rooms. Focusing a zone-less floor keeps the // building intact (otherwise its roof would just vanish with nothing to show). const revealing = !walk && selection.levelId != null && levelsWithZones.has(selection.levelId) - for (const mesh of occluderOwnMeshes) mesh.visible = !revealing + // Shadow-caster-only: hidden roof/ceiling meshes keep casting sun shadows + // so interiors show window light patches instead of uniform sun flood. + for (const mesh of occluderOwnMeshes) { + if (revealing) applyShadowOnly(mesh) + else clearShadowOnly(mesh) + } for (const { id, levelId, meshes, uniforms } of zoneFills.current) { const show = @@ -1031,7 +1041,7 @@ export function GlbScene({ outliner.hoveredObjects.length = 0 document.body.style.cursor = 'auto' // Restore ceilings/roof — the GLB scene is cached by drei and may be reused. - for (const mesh of occluderOwnMeshes) mesh.visible = true + for (const mesh of occluderOwnMeshes) clearShadowOnly(mesh) }, [occluderOwnMeshes], ) diff --git a/packages/viewer/src/components/viewer/ground-occluder.tsx b/packages/viewer/src/components/viewer/ground-occluder.tsx deleted file mode 100644 index b939fb16f..000000000 --- a/packages/viewer/src/components/viewer/ground-occluder.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { type LevelNode, useScene } from '@pascal-app/core' -import { useMemo } from 'react' -import * as THREE from 'three' -import { unionPolygons } from '../../lib/polygon-union' -import { getSceneTheme } from '../../lib/scene-themes' -import useViewer from '../../store/use-viewer' - -export const GroundOccluder = () => { - const bgColor = useViewer((state) => getSceneTheme(state.sceneTheme).ground) - - const nodes = useScene((state) => state.nodes) - - const shape = useMemo(() => { - const s = new THREE.Shape() - const size = 1000 - // Create outer infinite plane - s.moveTo(-size, -size) - s.lineTo(size, -size) - s.lineTo(size, size) - s.lineTo(-size, size) - s.closePath() - - const levelIndexById = new Map() - let lowestLevelIndex = Number.POSITIVE_INFINITY - - Object.values(nodes).forEach((node) => { - if (node.type !== 'level') { - return - } - - levelIndexById.set(node.id, node.level) - lowestLevelIndex = Math.min(lowestLevelIndex, node.level) - }) - - // Only the lowest level should punch through the ground plane. - // Upper-level slabs should still cast shadows, but they should not - // reveal their footprint on the level-zero ground material. - const polygons: [number, number][][] = [] - - Object.values(nodes).forEach((node) => { - if ( - !( - node.type === 'slab' && - node.visible && - node.polygon.length >= 3 && - // Only recessed slabs should punch through the ground plane. - (node.elevation ?? 0.05) < 0 - ) - ) { - return - } - - if (Number.isFinite(lowestLevelIndex)) { - const parentLevelIndex = node.parentId - ? levelIndexById.get(node.parentId as LevelNode['id']) - : undefined - - if (parentLevelIndex !== lowestLevelIndex) { - return - } - } - - polygons.push(node.polygon as [number, number][]) - }) - - if (polygons.length > 0) { - for (const ring of unionPolygons(polygons.map((pts) => pts.map((p) => [p[0], -p[1]])))) { - if (ring.length < 3) continue - const hole = new THREE.Path() - - hole.moveTo(ring[0]![0], ring[0]![1]) - for (let i = 1; i < ring.length; i++) { - hole.lineTo(ring[i]![0], ring[i]![1]) - } - hole.closePath() - s.holes.push(hole) - } - } - - return s - }, [nodes]) - - return ( - - - - - ) -} diff --git a/packages/viewer/src/components/viewer/lights.tsx b/packages/viewer/src/components/viewer/lights.tsx index 29ddc2c9e..56beb1cf1 100644 --- a/packages/viewer/src/components/viewer/lights.tsx +++ b/packages/viewer/src/components/viewer/lights.tsx @@ -8,6 +8,7 @@ import type { OrthographicCamera, } from 'three/webgpu' import * as THREE from 'three/webgpu' +import { SHADOW_ONLY_LAYER } from '../../lib/layers' import { getSceneTheme } from '../../lib/scene-themes' import useViewer from '../../store/use-viewer' @@ -152,6 +153,9 @@ export function Lights() { // the below. const cam = light.shadow?.camera as THREE.OrthographicCamera | undefined if (cam) { + // Shadow-caster-only geometry (hidden roofs/levels in cutaway views) + // is visible to the shadow pass alone — see lib/shadow-only.ts. + cam.layers.enable(SHADOW_ONLY_LAYER) cam.left = -size cam.right = size cam.top = size diff --git a/packages/viewer/src/lib/layers.ts b/packages/viewer/src/lib/layers.ts index b169fa031..a0603be00 100644 --- a/packages/viewer/src/lib/layers.ts +++ b/packages/viewer/src/lib/layers.ts @@ -23,3 +23,14 @@ export const ZONE_LAYER = 2 * from thumbnails like the other editor-only layers. */ export const GRID_LAYER = 3 + +/** + * Layer for geometry hidden from the color passes but still rendered into the + * shadow map ("shadow-caster-only"). Used when cutaway/solo views hide roofs + * or non-selected levels: the sun keeps casting their shadows, so interiors + * get window-shaped light patches instead of flooding with uniform sun. + * No camera or pass enables this layer — only each shadow-casting light's + * shadow camera does (see `lights.tsx`). Applied per-object (layers don't + * cascade) via `applyShadowOnly` / `clearShadowOnly` in `lib/shadow-only.ts`. + */ +export const SHADOW_ONLY_LAYER = 4 diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index 27ade98f5..2f1e1bacc 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -11,6 +11,7 @@ import { type SurfaceRole, } from '@pascal-app/core' import * as THREE from 'three' +import { float, mix, positionViewDirection, transformedNormalView } from 'three/tsl' import { MeshLambertNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu' import { resolveCdnUrl } from './asset-url' @@ -395,6 +396,32 @@ function applyMaterialMapProperties( material.needsUpdate = true } +// Glass-like transparency threshold: any standard material authored as +// `transparent` with opacity below this gets the fresnel treatment. +const GLASS_OPACITY_THRESHOLD = 0.6 + +/** + * Fresnel-driven opacity for glass: nearly the authored opacity head-on, + * increasingly opaque (showing the environment reflection) at grazing angles. + * This is what makes glass read as a surface instead of a flat blue tint. + */ +function applyGlassFresnel(material: MeshStandardNodeMaterial) { + const facing = transformedNormalView.dot(positionViewDirection).clamp(0, 1) + const fresnel = facing.oneMinus().pow(3) + material.opacityNode = mix(float(material.opacity), float(0.92), fresnel) + material.envMapIntensity = 1.4 +} + +function maybeApplyGlassFresnel(material: THREE.Material) { + if ( + material instanceof MeshStandardNodeMaterial && + material.transparent && + material.opacity < GLASS_OPACITY_THRESHOLD + ) { + applyGlassFresnel(material) + } +} + function applyMaterialPresetTextures(material: CommonMaterial, preset: MaterialPresetPayload) { const { maps, mapProperties } = preset @@ -447,6 +474,7 @@ export function createMaterialFromPreset( const material = shading === 'solid' ? new MeshLambertNodeMaterial() : new MeshStandardNodeMaterial() applyMaterialPresetToMaterials(material, preset) + maybeApplyGlassFresnel(material) materialCache.set(cacheKey, material) return material } @@ -496,6 +524,7 @@ export function createMaterial( metalness: props.metalness, }) + maybeApplyGlassFresnel(threeMaterial) materialCache.set(cacheKey, threeMaterial) return threeMaterial } @@ -655,6 +684,7 @@ export function DEFAULT_WINDOW_MATERIAL(shading: RenderShading = 'rendered'): TH roughness: 0.1, metalness: 0.1, }) + maybeApplyGlassFresnel(material) defaultMaterialCache.set(cacheKey, material) return material } diff --git a/packages/viewer/src/lib/scene-themes.ts b/packages/viewer/src/lib/scene-themes.ts index 139650a58..c3da35c58 100644 --- a/packages/viewer/src/lib/scene-themes.ts +++ b/packages/viewer/src/lib/scene-themes.ts @@ -8,7 +8,7 @@ export type SceneTheme = { // the site ground fill. The 3D background + lights come from the fields below. appearance: 'light' | 'dark' background: string - // Colour of the site ground fill + infinite ground-occluder plane. Kept + // Colour of the site ground fill. Kept // separate from `background` so dark themes can have a lit ground that reads // as ground rather than going near-black. ground: string diff --git a/packages/viewer/src/lib/shadow-only.ts b/packages/viewer/src/lib/shadow-only.ts new file mode 100644 index 000000000..c63c10ab8 --- /dev/null +++ b/packages/viewer/src/lib/shadow-only.ts @@ -0,0 +1,42 @@ +import type { Object3D } from 'three' +import { SCENE_LAYER, SHADOW_ONLY_LAYER } from './layers' + +/** + * Shadow-caster-only hiding: removes an object (and its descendants) from the + * color passes while keeping it in the shadow map, so a hidden roof or level + * still shadows the interior and sun enters through windows correctly. + * + * Uses layer masks instead of `visible = false` for two reasons: `visible` + * cascades (and, critically, prunes the object from the shadow pass too), + * while layers are tested per-object against the rendering camera — the main + * camera never enables {@link SHADOW_ONLY_LAYER}, but every shadow-casting + * light's shadow camera does (see `lights.tsx`). + * + * The original `layers.mask` is stashed under a private Symbol so + * {@link clearShadowOnly} restores the exact prior state. Both calls are + * idempotent and cheap to reapply. + */ + +const ORIGINAL_LAYERS = Symbol('pascal:shadow-only:original-layers') + +type ShadowOnlyCarrier = Object3D & { [ORIGINAL_LAYERS]?: number } + +export function applyShadowOnly(root: Object3D): void { + root.traverse((obj) => { + const carrier = obj as ShadowOnlyCarrier + if (carrier[ORIGINAL_LAYERS] === undefined) { + carrier[ORIGINAL_LAYERS] = obj.layers.mask + } + obj.layers.disable(SCENE_LAYER) + obj.layers.enable(SHADOW_ONLY_LAYER) + }) +} + +export function clearShadowOnly(root: Object3D): void { + root.traverse((obj) => { + const carrier = obj as ShadowOnlyCarrier + if (carrier[ORIGINAL_LAYERS] === undefined) return + obj.layers.mask = carrier[ORIGINAL_LAYERS] + delete carrier[ORIGINAL_LAYERS] + }) +} diff --git a/packages/viewer/src/systems/level/level-system.tsx b/packages/viewer/src/systems/level/level-system.tsx index 638778738..025f9f107 100644 --- a/packages/viewer/src/systems/level/level-system.tsx +++ b/packages/viewer/src/systems/level/level-system.tsx @@ -1,10 +1,18 @@ import { getLevelHeight, type LevelNode, sceneRegistry, useScene } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' +import type { Object3D } from 'three' import { lerp } from 'three/src/math/MathUtils.js' +import { applyShadowOnly, clearShadowOnly } from '../../lib/shadow-only' import useViewer from '../../store/use-viewer' const EXPLODED_GAP = 5 +// Levels currently in shadow-caster-only mode (solo hides them from the color +// passes but keeps their sun shadows). Tracked so we can restore layer masks +// exactly once on transition; apply re-runs every frame so meshes rebuilt +// while hidden (theme/texture changes) get re-hidden. +const shadowOnlyLevels = new WeakSet() + export const LevelSystem = () => { useFrame((_, delta) => { const nodes = useScene.getState().nodes @@ -37,7 +45,18 @@ export const LevelSystem = () => { const targetY = baseY + explodedExtra obj.position.y = lerp(obj.position.y, targetY, delta * 12) // Smoothly animate to new Y position - obj.visible = levelMode !== 'solo' || level?.id === selectedLevel || !selectedLevel + + // Solo: hidden levels stay in the shadow map (shadow-caster-only) so the + // sun still shadows the soloed floor through hidden roofs/floors above. + const hidden = levelMode === 'solo' && Boolean(selectedLevel) && level?.id !== selectedLevel + if (hidden) { + applyShadowOnly(obj) + shadowOnlyLevels.add(obj) + } else if (shadowOnlyLevels.has(obj)) { + clearShadowOnly(obj) + shadowOnlyLevels.delete(obj) + } + obj.visible = true cumulativeY += getLevelHeight( levelId, diff --git a/wiki/architecture/layers.md b/wiki/architecture/layers.md index 8943526c0..c77ba157d 100644 --- a/wiki/architecture/layers.md +++ b/wiki/architecture/layers.md @@ -14,6 +14,7 @@ Three.js `Layers` control which objects each camera and render pass sees. We use | `OVERLAY_LAYER` | `1` | `@pascal-app/viewer` | Editor overlays: gizmos, move handles, tool previews, cursor meshes, snap guides. Composited on top in its own pass. | | `ZONE_LAYER` | `2` | `@pascal-app/viewer` | Zone floor fills and wall borders — composited in a separate post-processing pass | | `GRID_LAYER` | `3` | `@pascal-app/viewer` | The editor ground grid — rendered *in* the scene pass for correct depth occlusion | +| `SHADOW_ONLY_LAYER` | `4` | `@pascal-app/viewer` | Shadow-caster-only geometry: hidden roofs/levels in cutaway/solo views. No color pass or camera enables it — only the sun's shadow camera (`lights.tsx`), so the geometry keeps shadowing interiors. Applied per-object via `lib/shadow-only.ts` (`applyShadowOnly`/`clearShadowOnly`). | `apps/editor` exposes `EDITOR_LAYER` for editor-helper meshes; it **re-exports** `OVERLAY_LAYER` (`EDITOR_LAYER === OVERLAY_LAYER`) so the editor stays decoupled from the viewer's pass numbering while landing on the same layer. From b0ff2ec48f8a770d5ac85a1e76b9b960f55ddda6 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 9 Jul 2026 18:49:35 -0400 Subject: [PATCH 04/16] feat(viewer): PCSS contact-hardening sun shadows via LightShadow.filterNode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Custom TSL filter: Vogel-disk blocker search (textureLoad — the sampled path would inherit the comparison sampler, which WGSL rejects) → receiver-blocker penumbra estimate → variable-radius rotated PCF. shadow.radius scales max penumbra (now 4). Co-Authored-By: Claude Fable 5 --- .../viewer/src/components/viewer/lights.tsx | 8 +- packages/viewer/src/lib/pcss.ts | 96 +++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 packages/viewer/src/lib/pcss.ts diff --git a/packages/viewer/src/components/viewer/lights.tsx b/packages/viewer/src/components/viewer/lights.tsx index 56beb1cf1..10075b236 100644 --- a/packages/viewer/src/components/viewer/lights.tsx +++ b/packages/viewer/src/components/viewer/lights.tsx @@ -9,6 +9,7 @@ import type { } from 'three/webgpu' import * as THREE from 'three/webgpu' import { SHADOW_ONLY_LAYER } from '../../lib/layers' +import { PCSSShadowFilter } from '../../lib/pcss' import { getSceneTheme } from '../../lib/scene-themes' import useViewer from '../../store/use-viewer' @@ -152,6 +153,11 @@ export function Lights() { // Resize the ortho frustum to the fitted bounds. The shadow camera is // the below. const cam = light.shadow?.camera as THREE.OrthographicCamera | undefined + if (light.shadow && (light.shadow as any).filterNode == null) { + // PCSS: contact-hardening penumbra instead of the renderer's + // uniform PCF blur. Assigned once per shadow instance. + ;(light.shadow as any).filterNode = PCSSShadowFilter + } if (cam) { // Shadow-caster-only geometry (hidden roofs/levels in cutaway views) // is visible to the shadow pass alone — see lib/shadow-only.ts. @@ -253,7 +259,7 @@ export function Lights() { shadow-bias={-0.002} shadow-mapSize={[2048, 2048]} shadow-normalBias={0.3} - shadow-radius={1.5} + shadow-radius={4} > {light.castShadow && !SHADOWS_DISABLED && shadows ? ( { + const mapSize: any = reference('mapSize', 'vec2', shadow) + const radius: any = reference('radius', 'float', shadow) + const texelSize = vec2(1).div(mapSize) + + // Raw depth reads use texelFetch (textureLoad) — the sampled path would + // inherit the comparison sampler the PCF taps bind, which WGSL rejects. + const depthAt = (uv: any) => { + const texel = uv.mul(mapSize).toIVec2() + let depth: any = textureLoad(depthTexture, texel) + if (depthTexture.isArrayTexture) depth = depth.depth(depthLayer) + return depth.x + } + const shadowCompare = (uv: any, compare: any) => { + let depth: any = texture(depthTexture, uv) + if (depthTexture.isArrayTexture) depth = depth.depth(depthLayer) + return depth.compare(compare) + } + + const receiverDepth: any = shadowCoord.z + + const phi = interleavedGradientNoise(screenCoordinate.xy).mul(TWO_PI) + + // 1. Blocker search: average depth of occluders inside the search disk. + const searchRadius = texelSize.x.mul(BLOCKER_SEARCH_TEXELS) + let blockerSum: any = float(0) + let blockerCount: any = float(0) + for (let i = 0; i < BLOCKER_SAMPLES; i++) { + const offset = vogelDiskSample(float(i), float(BLOCKER_SAMPLES), phi).mul(searchRadius) + const sampleDepth = depthAt(shadowCoord.xy.add(offset)) + const isBlocker = sampleDepth.lessThan(receiverDepth).toFloat() + blockerSum = blockerSum.add(sampleDepth.mul(isBlocker)) + blockerCount = blockerCount.add(isBlocker) + } + const avgBlocker = blockerSum.div(blockerCount.max(1)) + + // 2. Penumbra estimate: receiver–blocker gap (parallel sun rays) scaled by + // the shadow's radius knob, clamped between crisp (1 texel) and the search + // window so the PCF disk never outruns the blocker estimate. + const penumbra = receiverDepth + .sub(avgBlocker) + .mul(PENUMBRA_SCALE) + .mul(radius) + .clamp(texelSize.x, searchRadius) + + // 3. Variable-radius PCF with the same rotated Vogel disk. + let lit: any = float(0) + for (let i = 0; i < PCF_SAMPLES; i++) { + const offset = vogelDiskSample(float(i), float(PCF_SAMPLES), phi).mul(penumbra) + lit = lit.add(shadowCompare(shadowCoord.xy.add(offset), receiverDepth)) + } + lit = lit.div(PCF_SAMPLES) + + // Fully lit when the blocker search found nothing (early-out semantics + // without branching). + return blockerCount.lessThanEqual(0).select(float(1), lit) + }, +) From 8919dc0916ca8ff438f5cce6a2277bdbb4ca3d86 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 9 Jul 2026 18:56:32 -0400 Subject: [PATCH 05/16] feat(viewer): dark-theme recalibration for the new lighting pipeline Night/twilight: lifted ambient/hemi beds (the 0.9 shadow intensity crushed them), brightened theme grounds to a lit mid-tone, and dimmed the daylight gradient-sky IBL to 0.2 for dark appearances. Co-Authored-By: Claude Fable 5 --- .../src/components/viewer/scene-environment.tsx | 9 +++++++-- packages/viewer/src/lib/scene-themes.ts | 16 ++++++++-------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/viewer/src/components/viewer/scene-environment.tsx b/packages/viewer/src/components/viewer/scene-environment.tsx index d01b99194..23c332d4b 100644 --- a/packages/viewer/src/components/viewer/scene-environment.tsx +++ b/packages/viewer/src/components/viewer/scene-environment.tsx @@ -3,6 +3,8 @@ import { useThree } from '@react-three/fiber' import { useEffect, useMemo } from 'react' import * as THREE from 'three/webgpu' +import { getSceneTheme } from '../../lib/scene-themes' +import useViewer from '../../store/use-viewer' /** * Scene IBL — a small procedural gradient sky (cool zenith → warm horizon → @@ -22,6 +24,8 @@ const HORIZON = [0.95, 0.84, 0.66] as const const GROUND = [0.38, 0.35, 0.3] as const const ENV_INTENSITY = 0.6 +// The gradient sky is a daylight source; dark themes only want a whisper of it. +const ENV_INTENSITY_DARK = 0.2 const WIDTH = 64 const HEIGHT = 32 @@ -64,18 +68,19 @@ function buildGradientSky(): THREE.DataTexture { export function SceneEnvironment() { const scene = useThree((state) => state.scene) const texture = useMemo(buildGradientSky, []) + const appearance = useViewer((state) => getSceneTheme(state.sceneTheme).appearance) useEffect(() => { const prevEnvironment = scene.environment const prevIntensity = scene.environmentIntensity scene.environment = texture - scene.environmentIntensity = ENV_INTENSITY + scene.environmentIntensity = appearance === 'dark' ? ENV_INTENSITY_DARK : ENV_INTENSITY return () => { scene.environment = prevEnvironment scene.environmentIntensity = prevIntensity texture.dispose() } - }, [scene, texture]) + }, [scene, texture, appearance]) return null } diff --git a/packages/viewer/src/lib/scene-themes.ts b/packages/viewer/src/lib/scene-themes.ts index c3da35c58..a59e619e6 100644 --- a/packages/viewer/src/lib/scene-themes.ts +++ b/packages/viewer/src/lib/scene-themes.ts @@ -153,9 +153,9 @@ export const SCENE_THEMES: SceneTheme[] = [ name: 'Twilight', appearance: 'dark', background: '#3a3550', - ground: '#4a4566', - ambient: { color: '#a89cc8', intensity: 0.35 }, - hemi: { sky: '#d8a8c0', ground: '#1c1830', intensity: 0.5 }, + ground: '#67618a', + ambient: { color: '#a89cc8', intensity: 0.55 }, + hemi: { sky: '#d8a8c0', ground: '#3a3450', intensity: 0.7 }, lights: [ { position: [-14, 22, -10], color: '#a4b6e8', intensity: 1.4, castShadow: true }, { position: [14, 6, 8], color: '#ffb070', intensity: 0.9 }, @@ -174,12 +174,12 @@ export const SCENE_THEMES: SceneTheme[] = [ name: 'Night', appearance: 'dark', background: '#1f2433', - ground: '#2b3247', - ambient: { color: '#a0b0ff', intensity: 0.07 }, - hemi: { sky: '#3a4666', ground: '#0e111c', intensity: 0.4 }, + ground: '#4a5470', + ambient: { color: '#a0b0ff', intensity: 0.25 }, + hemi: { sky: '#3a4666', ground: '#232a3d', intensity: 0.55 }, lights: [ - { position: [10, 10, 10], color: '#e0e5ff', intensity: 0.8, castShadow: true }, - { position: [-10, 10, -10], color: '#8090ff', intensity: 0.2 }, + { position: [10, 10, 10], color: '#e0e5ff', intensity: 1.2, castShadow: true }, + { position: [-10, 10, -10], color: '#8090ff', intensity: 0.3 }, ], toneMappingExposure: 0.9, clayTints: { From b41394ae136014f9fbcbb1405175655832e3a21d Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 9 Jul 2026 19:58:41 -0400 Subject: [PATCH 06/16] =?UTF-8?q?feat(viewer):=20rendering-pass=20follow-u?= =?UTF-8?q?ps=20=E2=80=94=20GI=20denoise,=20lighter=20interactive=20SSGI,?= =?UTF-8?q?=20sky=20gradients,=20scoped=20solo=20shadows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Denoise the SSGI GI bounce (it composited raw — the visible grain) and drop giIntensity to 1 - Interactive SSGI back to 1 slice (×6 steps); SSGI_BAKE_PARAMS (2×6) for the thumbnail/bake pipeline - Per-theme backgroundSky: vertical zenith→horizon backdrop gradient in the post pipeline; makes the lot-edge ground fade read in every theme - Level solo: only levels above the soloed floor stay shadow-caster-only; below-levels plain-hide (they can't block the sun) Co-Authored-By: Claude Fable 5 --- .../components/editor/thumbnail-generator.tsx | 24 ++++++------ .../src/components/viewer/glb-scene.tsx | 15 +++++-- .../src/components/viewer/post-processing.tsx | 39 ++++++++++++++++--- packages/viewer/src/index.ts | 2 +- packages/viewer/src/lib/scene-themes.ts | 13 +++++++ .../viewer/src/systems/level/level-system.tsx | 22 +++++++---- 6 files changed, 85 insertions(+), 30 deletions(-) diff --git a/packages/editor/src/components/editor/thumbnail-generator.tsx b/packages/editor/src/components/editor/thumbnail-generator.tsx index 72c840a85..fd883e911 100644 --- a/packages/editor/src/components/editor/thumbnail-generator.tsx +++ b/packages/editor/src/components/editor/thumbnail-generator.tsx @@ -1,7 +1,7 @@ 'use client' import { emitter, sceneRegistry } from '@pascal-app/core' -import { GRID_LAYER, SSGI_PARAMS, snapLevelsToTruePositions, useViewer } from '@pascal-app/viewer' +import { GRID_LAYER, SSGI_BAKE_PARAMS, snapLevelsToTruePositions, useViewer } from '@pascal-app/viewer' import type { CameraControls } from '@react-three/drei' import { useThree } from '@react-three/fiber' import { useCallback, useEffect, useRef } from 'react' @@ -94,17 +94,17 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro const sceneNormal = sample((uv) => colorToDirection(scenePassNormal.sample(uv))) const giPass = ssgi(scenePassColor, scenePassDepth, sceneNormal, cam 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 + giPass.sliceCount.value = SSGI_BAKE_PARAMS.sliceCount + giPass.stepCount.value = SSGI_BAKE_PARAMS.stepCount + giPass.radius.value = SSGI_BAKE_PARAMS.radius + giPass.expFactor.value = SSGI_BAKE_PARAMS.expFactor + giPass.thickness.value = SSGI_BAKE_PARAMS.thickness + giPass.backfaceLighting.value = SSGI_BAKE_PARAMS.backfaceLighting + giPass.aoIntensity.value = SSGI_BAKE_PARAMS.aoIntensity + giPass.giIntensity.value = SSGI_BAKE_PARAMS.giIntensity + giPass.useLinearThickness.value = SSGI_BAKE_PARAMS.useLinearThickness + giPass.useScreenSpaceSampling.value = SSGI_BAKE_PARAMS.useScreenSpaceSampling + giPass.useTemporalFiltering = SSGI_BAKE_PARAMS.useTemporalFiltering const giTexture = (giPass as any).getTextureNode() const aoAsRgb = vec4(giTexture.a, giTexture.a, giTexture.a, float(1)) diff --git a/packages/viewer/src/components/viewer/glb-scene.tsx b/packages/viewer/src/components/viewer/glb-scene.tsx index f0b2a19c0..7f64f6227 100644 --- a/packages/viewer/src/components/viewer/glb-scene.tsx +++ b/packages/viewer/src/components/viewer/glb-scene.tsx @@ -593,18 +593,25 @@ export function GlbScene({ if (levels.length === 0) return const { levelMode, selection, walkthroughMode } = useViewer.getState() const selectedLevel = selection.levelId + const selectedIdx = selectedLevel ? levels.findIndex((l) => l.id === selectedLevel) : -1 levels.forEach(({ id, node, baseY }, index) => { const exploded = !walkthroughMode && levelMode === 'exploded' const targetY = baseY + (exploded ? index * EXPLODED_GAP : 0) // Snap (not lerp) in walkthrough so the first-person collider, built from // these world positions, matches the stacked building immediately. node.position.y = walkthroughMode ? targetY : lerp(node.position.y, targetY, delta * 12) - // Solo keeps hidden levels casting shadows (shadow-caster-only layers). + // Solo: hidden levels above the soloed one keep casting shadows + // (shadow-caster-only); below-levels can't block the sun, so plain-hide. const hidden = !walkthroughMode && levelMode === 'solo' && Boolean(selectedLevel) && id !== selectedLevel - if (hidden) applyShadowOnly(node) - else clearShadowOnly(node) - node.visible = true + const castsWhileHidden = hidden && selectedIdx >= 0 && index > selectedIdx + if (castsWhileHidden) { + applyShadowOnly(node) + node.visible = true + } else { + clearShadowOnly(node) + node.visible = !hidden + } }) }, 5) diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index 6188ea199..f300ce854 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -19,6 +19,7 @@ import { renderOutput, sample, saturation, + screenUV, time, uniform, vec3, @@ -44,19 +45,26 @@ export const GRADE_PARAMS = { // SSGI Parameters - adjust these to fine-tune global illumination and ambient occlusion export const SSGI_PARAMS = { enabled: true, - sliceCount: 2, + sliceCount: 1, stepCount: 6, radius: 1.6, expFactor: 1.5, thickness: 0.5, backfaceLighting: 0.5, aoIntensity: 1.7, - giIntensity: 2, + giIntensity: 1, useLinearThickness: false, useScreenSpaceSampling: true, useTemporalFiltering: false, } +// Heavier SSGI for one-shot renders (thumbnails / bake capture) where frame +// time doesn't matter — the interactive editor stays on SSGI_PARAMS. +export const SSGI_BAKE_PARAMS = { + ...SSGI_PARAMS, + sliceCount: 2, +} + // Diagnostic toggles for thermal A/B testing. Add `?disable=ao,denoise,outline,postFx` // to the URL (any subset) and reload to skip those passes. Each flag prevents // allocation + per-frame work for that stage, so device temperature deltas @@ -151,10 +159,16 @@ const PostProcessingPasses = ({ // Background color uniform — updated every frame via lerp, read by the TSL pipeline. // Initialised from the current scene theme so there's no flash on first render. - const initBg = getSceneTheme(useViewer.getState().sceneTheme).background + const initTheme = getSceneTheme(useViewer.getState().sceneTheme) + const initBg = initTheme.background const bgUniform = useRef(uniform(new Color(initBg))) const bgCurrent = useRef(new Color(initBg)) const bgTarget = useRef(new Color()) + // Zenith colour of the backdrop gradient (falls back to the flat background). + const initSky = initTheme.backgroundSky ?? initBg + const bgSkyUniform = useRef(uniform(new Color(initSky))) + const bgSkyCurrent = useRef(new Color(initSky)) + const bgSkyTarget = useRef(new Color()) // Ink-line colour follows the scene-theme background luminance (dark lines on // light scenes, light on dark), refreshed each frame like the background. @@ -413,7 +427,7 @@ const PostProcessingPasses = ({ const giTexture = (giPass as any).getTextureNode() - const gi = giPass.rgb + let gi: any = giPass.rgb let ao: any if (denoiseEnabled) { // DenoiseNode only denoises RGB — alpha is passed through unchanged. @@ -423,6 +437,12 @@ const PostProcessingPasses = ({ denoisePass.index.value = 0 denoisePass.radius.value = 4 ao = (denoisePass as any).r + // The GI bounce is composited additively, so its sampling noise reads + // as grain on lit surfaces — denoise it like the AO. + const giDenoise = denoise(vec4(gi, float(1)), scenePassDepth, sceneNormal, camera) + giDenoise.index.value = 1 + giDenoise.radius.value = 4 + gi = (giDenoise as any).rgb } else { // Diagnostic path: feed raw noisy SSGI AO straight through. Will // look grainy — that's the point, it isolates denoise cost. @@ -506,7 +526,10 @@ const PostProcessingPasses = ({ ) } - const composited = mix(bgUniform.current, compositeWithOutlines.rgb, contentAlpha) + // Backdrop: vertical sky gradient (theme zenith at the top of the screen, + // horizon colour at the bottom) behind the scene content. + const bgGradient = mix(bgSkyUniform.current, bgUniform.current, screenUV.y) + const composited = mix(bgGradient, compositeWithOutlines.rgb, contentAlpha) // Editor overlays painted on top by their own alpha — they never get inked, // AO'd, or outlined, and always read crisp regardless of scene depth. const withOverlay = mix(composited, overlayColor.rgb, overlayColor.a) @@ -584,9 +607,13 @@ const PostProcessingPasses = ({ } // Animate background colour toward the current scene theme target (same lerp as AnimatedBackground) - bgTarget.current.set(getSceneTheme(useViewer.getState().sceneTheme).background) + const bgTheme = getSceneTheme(useViewer.getState().sceneTheme) + bgTarget.current.set(bgTheme.background) bgCurrent.current.lerp(bgTarget.current, Math.min(delta, 0.1) * 4) bgUniform.current.value.copy(bgCurrent.current) + bgSkyTarget.current.set(bgTheme.backgroundSky ?? bgTheme.background) + bgSkyCurrent.current.lerp(bgSkyTarget.current, Math.min(delta, 0.1) * 4) + bgSkyUniform.current.value.copy(bgSkyCurrent.current) // Ink colour follows the (lerping) background luminance — snaps dark↔light. inkColorUniform.current.value.set(edgeColorFor(`#${bgCurrent.current.getHexString()}`)) diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index fa786bb98..9f49334f9 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -38,7 +38,7 @@ export { GlbWalkthroughController } from './components/viewer/glb-walkthrough-co export type { HoverStyle, HoverStyles } from './components/viewer/post-processing' export { DEFAULT_HOVER_STYLES, - SSGI_PARAMS, + SSGI_BAKE_PARAMS, SSGI_PARAMS, } from './components/viewer/post-processing' export { SceneEnvironment } from './components/viewer/scene-environment' export { WalkthroughControls } from './components/viewer/walkthrough-controls' diff --git a/packages/viewer/src/lib/scene-themes.ts b/packages/viewer/src/lib/scene-themes.ts index a59e619e6..73525fa33 100644 --- a/packages/viewer/src/lib/scene-themes.ts +++ b/packages/viewer/src/lib/scene-themes.ts @@ -8,6 +8,10 @@ export type SceneTheme = { // the site ground fill. The 3D background + lights come from the fields below. appearance: 'light' | 'dark' background: string + // Optional zenith colour for the backdrop: the post pipeline renders a + // vertical screen-space gradient from this (top) to `background` (horizon). + // Omitted → flat `background`, as before. + backgroundSky?: string // Colour of the site ground fill. Kept // separate from `background` so dark themes can have a lit ground that reads // as ground rather than going near-black. @@ -30,6 +34,7 @@ export const SCENE_THEMES: SceneTheme[] = [ name: 'Studio', appearance: 'light', background: '#ffffff', + backgroundSky: '#e7edf3', ground: '#f4f4f2', ambient: { color: '#ffffff', intensity: 0.15 }, hemi: { sky: '#ffffff', ground: '#aaa49a', intensity: 0.45 }, @@ -51,6 +56,7 @@ export const SCENE_THEMES: SceneTheme[] = [ name: 'Paper', appearance: 'light', background: '#ede9df', + backgroundSky: '#ded7c6', ground: '#e7e1d3', ambient: { color: '#fff9eb', intensity: 0.55 }, hemi: { sky: '#fff5d9', ground: '#c2b89c', intensity: 0.35 }, @@ -72,6 +78,7 @@ export const SCENE_THEMES: SceneTheme[] = [ name: 'Sunset', appearance: 'light', background: '#f6e8d4', + backgroundSky: '#b5bede', ground: '#ecd9bf', ambient: { color: '#ffd9a8', intensity: 0.45 }, hemi: { sky: '#ffd9a8', ground: '#5b4634', intensity: 0.4 }, @@ -93,6 +100,7 @@ export const SCENE_THEMES: SceneTheme[] = [ name: 'Overcast', appearance: 'light', background: '#e6e7e6', + backgroundSky: '#cfd3d2', ground: '#dadcd9', ambient: { color: '#eef0ef', intensity: 1.1 }, hemi: { sky: '#f4f5f3', ground: '#bcbfbb', intensity: 0.9 }, @@ -111,6 +119,7 @@ export const SCENE_THEMES: SceneTheme[] = [ name: 'Blueprint', appearance: 'light', background: '#dde6ef', + backgroundSky: '#bfd2e6', ground: '#c9d6e6', ambient: { color: '#cfdcec', intensity: 0.7 }, hemi: { sky: '#dfeaf6', ground: '#5b6b80', intensity: 0.55 }, @@ -132,6 +141,7 @@ export const SCENE_THEMES: SceneTheme[] = [ name: 'Mediterranean', appearance: 'light', background: '#bdd6e8', + backgroundSky: '#8ab4d6', ground: '#ddd2bb', ambient: { color: '#d6e6f3', intensity: 0.5 }, hemi: { sky: '#a8c8e2', ground: '#d8c9a4', intensity: 0.6 }, @@ -153,6 +163,7 @@ export const SCENE_THEMES: SceneTheme[] = [ name: 'Twilight', appearance: 'dark', background: '#3a3550', + backgroundSky: '#272338', ground: '#67618a', ambient: { color: '#a89cc8', intensity: 0.55 }, hemi: { sky: '#d8a8c0', ground: '#3a3450', intensity: 0.7 }, @@ -174,6 +185,7 @@ export const SCENE_THEMES: SceneTheme[] = [ name: 'Night', appearance: 'dark', background: '#1f2433', + backgroundSky: '#12161f', ground: '#4a5470', ambient: { color: '#a0b0ff', intensity: 0.25 }, hemi: { sky: '#3a4666', ground: '#232a3d', intensity: 0.55 }, @@ -195,6 +207,7 @@ export const SCENE_THEMES: SceneTheme[] = [ name: 'Verdant', appearance: 'light', background: '#d6e4d2', + backgroundSky: '#bcd4c6', ground: '#c7d6b4', ambient: { color: '#e3efdd', intensity: 0.5 }, hemi: { sky: '#cfe6cf', ground: '#8ea06f', intensity: 0.65 }, diff --git a/packages/viewer/src/systems/level/level-system.tsx b/packages/viewer/src/systems/level/level-system.tsx index 025f9f107..b32b976cf 100644 --- a/packages/viewer/src/systems/level/level-system.tsx +++ b/packages/viewer/src/systems/level/level-system.tsx @@ -37,6 +37,9 @@ export const LevelSystem = () => { entries.sort((a, b) => a.index - b.index) // Walk sorted levels, accumulating base Y offsets + const selectedIndex = selectedLevel + ? entries.find((e) => e.levelId === selectedLevel)?.index + : undefined let cumulativeY = 0 for (const { levelId, index, obj } of entries) { const level = nodes[levelId as LevelNode['id']] @@ -46,17 +49,22 @@ export const LevelSystem = () => { obj.position.y = lerp(obj.position.y, targetY, delta * 12) // Smoothly animate to new Y position - // Solo: hidden levels stay in the shadow map (shadow-caster-only) so the - // sun still shadows the soloed floor through hidden roofs/floors above. + // Solo: hidden levels ABOVE the soloed one stay in the shadow map + // (shadow-caster-only) so the sun still shadows the soloed floor through + // them; levels below can't block the sun, so they plain-hide. const hidden = levelMode === 'solo' && Boolean(selectedLevel) && level?.id !== selectedLevel - if (hidden) { + const castsWhileHidden = hidden && selectedIndex !== undefined && index > selectedIndex + if (castsWhileHidden) { applyShadowOnly(obj) shadowOnlyLevels.add(obj) - } else if (shadowOnlyLevels.has(obj)) { - clearShadowOnly(obj) - shadowOnlyLevels.delete(obj) + obj.visible = true + } else { + if (shadowOnlyLevels.has(obj)) { + clearShadowOnly(obj) + shadowOnlyLevels.delete(obj) + } + obj.visible = !hidden } - obj.visible = true cumulativeY += getLevelHeight( levelId, From fa449f155d9f736fbc050f43a767fb18f6f73075 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 9 Jul 2026 20:10:24 -0400 Subject: [PATCH 07/16] =?UTF-8?q?feat(viewer):=20kill=20SSGI=20grain,=20re?= =?UTF-8?q?al=20horizon=20=E2=80=94=20infinite=20ground=20disc=20fading=20?= =?UTF-8?q?into=20the=20sky?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SSGI back to 2 slices × 6 steps: three's own minimum preset without temporal filtering — 1×6 was below the floor and the grain showed on flat walls; denoise radius 5 on both AO and GI, aoIntensity 1.5; bake preset raised to 3×8 (single-frame renders) - Site renderer: presentation horizon disc (8× lot radius, min 400 m) under the lot in the theme ground colour, fading radially into the theme background; lot fill back to plain ground colour (the disc carries the fade); never pickable (noop raycast) - Backdrop sky gradient compressed to the upper half of the screen so it meets the disc's far fade at exactly the horizon colour — no seam Co-Authored-By: Claude Fable 5 --- .../components/editor/thumbnail-generator.tsx | 7 +- packages/nodes/src/site/renderer.tsx | 58 +++++++--- .../src/components/viewer/post-processing.tsx | 24 +++-- packages/viewer/src/index.ts | 3 +- packages/viewer/src/lib/pcss.ts | 102 +++++++++--------- 5 files changed, 118 insertions(+), 76 deletions(-) diff --git a/packages/editor/src/components/editor/thumbnail-generator.tsx b/packages/editor/src/components/editor/thumbnail-generator.tsx index fd883e911..88363199e 100644 --- a/packages/editor/src/components/editor/thumbnail-generator.tsx +++ b/packages/editor/src/components/editor/thumbnail-generator.tsx @@ -1,7 +1,12 @@ 'use client' import { emitter, sceneRegistry } from '@pascal-app/core' -import { GRID_LAYER, SSGI_BAKE_PARAMS, snapLevelsToTruePositions, useViewer } from '@pascal-app/viewer' +import { + GRID_LAYER, + SSGI_BAKE_PARAMS, + snapLevelsToTruePositions, + useViewer, +} from '@pascal-app/viewer' import type { CameraControls } from '@react-three/drei' import { useThree } from '@react-three/fiber' import { useCallback, useEffect, useRef } from 'react' diff --git a/packages/nodes/src/site/renderer.tsx b/packages/nodes/src/site/renderer.tsx index bea5dc2b7..1adcb2e41 100644 --- a/packages/nodes/src/site/renderer.tsx +++ b/packages/nodes/src/site/renderer.tsx @@ -18,6 +18,7 @@ import { import { useEffect, useMemo, useRef } from 'react' import { BufferGeometry, + CircleGeometry, Float32BufferAttribute, type Group, Path, @@ -29,6 +30,10 @@ import { MeshLambertNodeMaterial } from 'three/webgpu' const Y_OFFSET = 0.01 +// The horizon disc is presentation-only — clicks must fall through to the +// real site polygon / grid, so its raycast is a no-op. +const noopRaycast = () => {} + /** * Creates simple line geometry for site boundary * Single horizontal line at ground level @@ -87,28 +92,41 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { // Lit (not Basic) so the site ground receives the directional shadow — Basic // is unlit, which is why shadows used to stop dead at the slab edge. polygonOffset // keeps it tucked behind the grid/slab as before. - // - // The ground fill fades radially into the theme background toward the lot - // boundary so the scene reads as a deliberate presentation vignette instead - // of a hard-edged plate floating on the backdrop. const groundMaterial = useMemo(() => { const material = new MeshLambertNodeMaterial({ color: bgColor }) - if (fadeBounds) { - const center = vec2(fadeBounds.cx, fadeBounds.cz) - const dist = positionWorld.xz.sub(center).length() - const fade = smoothstep( - float(fadeBounds.radius * 0.45), - float(fadeBounds.radius * 0.98), - dist, - ) - material.colorNode = mix(color(bgColor), color(backgroundColor), fade) - } material.polygonOffset = true material.polygonOffsetFactor = 1 material.polygonOffsetUnits = 1 return material + }, [bgColor]) + + // Presentation horizon: a large ground disc under the lot, in the same + // theme ground colour, fading radially into the theme background so the + // scene sits on an "infinite" plane that dissolves into the sky instead of + // a hard-edged plate floating on the backdrop. Never pickable. + const horizonMaterial = useMemo(() => { + if (!fadeBounds) return null + const material = new MeshLambertNodeMaterial({ color: bgColor }) + const center = vec2(fadeBounds.cx, fadeBounds.cz) + const dist = positionWorld.xz.sub(center).length() + const fade = smoothstep( + float(fadeBounds.radius * 1.05), + float(fadeBounds.radius * 5), + dist, + ) + material.colorNode = mix(color(bgColor), color(backgroundColor), fade) + material.polygonOffset = true + material.polygonOffsetFactor = 2 + material.polygonOffsetUnits = 2 + return material }, [bgColor, backgroundColor, fadeBounds]) + const horizonGeometry = useMemo(() => { + if (!fadeBounds) return null + return new CircleGeometry(Math.max(fadeBounds.radius * 8, 400), 64) + }, [fadeBounds]) + useEffect(() => () => horizonGeometry?.dispose(), [horizonGeometry]) + // Cache slab polygon references to keep the selector stable across unrelated store updates const slabPolygonsCache = useRef<[number, number][][]>([]) const slabPolygons = useScene((state: S) => { @@ -207,6 +225,18 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { /> )} + {/* Infinite-ground presentation disc fading into the sky at the horizon */} + {horizonGeometry && horizonMaterial && fadeBounds && ( + + )} + {/* Simple boundary line */} {/* @ts-ignore */} diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index f300ce854..3fdd43c0c 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -20,6 +20,7 @@ import { sample, saturation, screenUV, + smoothstep, time, uniform, vec3, @@ -45,13 +46,13 @@ export const GRADE_PARAMS = { // SSGI Parameters - adjust these to fine-tune global illumination and ambient occlusion export const SSGI_PARAMS = { enabled: true, - sliceCount: 1, + sliceCount: 2, stepCount: 6, radius: 1.6, expFactor: 1.5, thickness: 0.5, backfaceLighting: 0.5, - aoIntensity: 1.7, + aoIntensity: 1.5, giIntensity: 1, useLinearThickness: false, useScreenSpaceSampling: true, @@ -62,7 +63,8 @@ export const SSGI_PARAMS = { // time doesn't matter — the interactive editor stays on SSGI_PARAMS. export const SSGI_BAKE_PARAMS = { ...SSGI_PARAMS, - sliceCount: 2, + sliceCount: 3, + stepCount: 8, } // Diagnostic toggles for thermal A/B testing. Add `?disable=ao,denoise,outline,postFx` @@ -435,13 +437,13 @@ const PostProcessingPasses = ({ 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 + denoisePass.radius.value = 5 ao = (denoisePass as any).r // The GI bounce is composited additively, so its sampling noise reads // as grain on lit surfaces — denoise it like the AO. const giDenoise = denoise(vec4(gi, float(1)), scenePassDepth, sceneNormal, camera) giDenoise.index.value = 1 - giDenoise.radius.value = 4 + giDenoise.radius.value = 5 gi = (giDenoise as any).rgb } else { // Diagnostic path: feed raw noisy SSGI AO straight through. Will @@ -526,9 +528,15 @@ const PostProcessingPasses = ({ ) } - // Backdrop: vertical sky gradient (theme zenith at the top of the screen, - // horizon colour at the bottom) behind the scene content. - const bgGradient = mix(bgSkyUniform.current, bgUniform.current, screenUV.y) + // Backdrop: vertical sky gradient (theme zenith at the top of the screen) + // compressed into the upper half so everything below mid-screen is pure + // horizon colour — the infinite-ground disc fades to that same colour, + // so the two meet seamlessly wherever the horizon lands. + const bgGradient = mix( + bgSkyUniform.current, + bgUniform.current, + smoothstep(float(0), float(0.5), screenUV.y), + ) const composited = mix(bgGradient, compositeWithOutlines.rgb, contentAlpha) // Editor overlays painted on top by their own alpha — they never get inked, // AO'd, or outlined, and always read crisp regardless of scene depth. diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 9f49334f9..b8bae1172 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -38,7 +38,8 @@ export { GlbWalkthroughController } from './components/viewer/glb-walkthrough-co export type { HoverStyle, HoverStyles } from './components/viewer/post-processing' export { DEFAULT_HOVER_STYLES, - SSGI_BAKE_PARAMS, SSGI_PARAMS, + SSGI_BAKE_PARAMS, + SSGI_PARAMS, } from './components/viewer/post-processing' export { SceneEnvironment } from './components/viewer/scene-environment' export { WalkthroughControls } from './components/viewer/walkthrough-controls' diff --git a/packages/viewer/src/lib/pcss.ts b/packages/viewer/src/lib/pcss.ts index ec3254fa6..f71bae8ab 100644 --- a/packages/viewer/src/lib/pcss.ts +++ b/packages/viewer/src/lib/pcss.ts @@ -35,62 +35,60 @@ const BLOCKER_SAMPLES = 8 const PCF_SAMPLES = 12 const TWO_PI = 6.28318530718 -export const PCSSShadowFilter = Fn( - ({ depthTexture, shadowCoord, shadow, depthLayer }: any) => { - const mapSize: any = reference('mapSize', 'vec2', shadow) - const radius: any = reference('radius', 'float', shadow) - const texelSize = vec2(1).div(mapSize) +export const PCSSShadowFilter = Fn(({ depthTexture, shadowCoord, shadow, depthLayer }: any) => { + const mapSize: any = reference('mapSize', 'vec2', shadow) + const radius: any = reference('radius', 'float', shadow) + const texelSize = vec2(1).div(mapSize) - // Raw depth reads use texelFetch (textureLoad) — the sampled path would - // inherit the comparison sampler the PCF taps bind, which WGSL rejects. - const depthAt = (uv: any) => { - const texel = uv.mul(mapSize).toIVec2() - let depth: any = textureLoad(depthTexture, texel) - if (depthTexture.isArrayTexture) depth = depth.depth(depthLayer) - return depth.x - } - const shadowCompare = (uv: any, compare: any) => { - let depth: any = texture(depthTexture, uv) - if (depthTexture.isArrayTexture) depth = depth.depth(depthLayer) - return depth.compare(compare) - } + // Raw depth reads use texelFetch (textureLoad) — the sampled path would + // inherit the comparison sampler the PCF taps bind, which WGSL rejects. + const depthAt = (uv: any) => { + const texel = uv.mul(mapSize).toIVec2() + let depth: any = textureLoad(depthTexture, texel) + if (depthTexture.isArrayTexture) depth = depth.depth(depthLayer) + return depth.x + } + const shadowCompare = (uv: any, compare: any) => { + let depth: any = texture(depthTexture, uv) + if (depthTexture.isArrayTexture) depth = depth.depth(depthLayer) + return depth.compare(compare) + } - const receiverDepth: any = shadowCoord.z + const receiverDepth: any = shadowCoord.z - const phi = interleavedGradientNoise(screenCoordinate.xy).mul(TWO_PI) + const phi = interleavedGradientNoise(screenCoordinate.xy).mul(TWO_PI) - // 1. Blocker search: average depth of occluders inside the search disk. - const searchRadius = texelSize.x.mul(BLOCKER_SEARCH_TEXELS) - let blockerSum: any = float(0) - let blockerCount: any = float(0) - for (let i = 0; i < BLOCKER_SAMPLES; i++) { - const offset = vogelDiskSample(float(i), float(BLOCKER_SAMPLES), phi).mul(searchRadius) - const sampleDepth = depthAt(shadowCoord.xy.add(offset)) - const isBlocker = sampleDepth.lessThan(receiverDepth).toFloat() - blockerSum = blockerSum.add(sampleDepth.mul(isBlocker)) - blockerCount = blockerCount.add(isBlocker) - } - const avgBlocker = blockerSum.div(blockerCount.max(1)) + // 1. Blocker search: average depth of occluders inside the search disk. + const searchRadius = texelSize.x.mul(BLOCKER_SEARCH_TEXELS) + let blockerSum: any = float(0) + let blockerCount: any = float(0) + for (let i = 0; i < BLOCKER_SAMPLES; i++) { + const offset = vogelDiskSample(float(i), float(BLOCKER_SAMPLES), phi).mul(searchRadius) + const sampleDepth = depthAt(shadowCoord.xy.add(offset)) + const isBlocker = sampleDepth.lessThan(receiverDepth).toFloat() + blockerSum = blockerSum.add(sampleDepth.mul(isBlocker)) + blockerCount = blockerCount.add(isBlocker) + } + const avgBlocker = blockerSum.div(blockerCount.max(1)) - // 2. Penumbra estimate: receiver–blocker gap (parallel sun rays) scaled by - // the shadow's radius knob, clamped between crisp (1 texel) and the search - // window so the PCF disk never outruns the blocker estimate. - const penumbra = receiverDepth - .sub(avgBlocker) - .mul(PENUMBRA_SCALE) - .mul(radius) - .clamp(texelSize.x, searchRadius) + // 2. Penumbra estimate: receiver–blocker gap (parallel sun rays) scaled by + // the shadow's radius knob, clamped between crisp (1 texel) and the search + // window so the PCF disk never outruns the blocker estimate. + const penumbra = receiverDepth + .sub(avgBlocker) + .mul(PENUMBRA_SCALE) + .mul(radius) + .clamp(texelSize.x, searchRadius) - // 3. Variable-radius PCF with the same rotated Vogel disk. - let lit: any = float(0) - for (let i = 0; i < PCF_SAMPLES; i++) { - const offset = vogelDiskSample(float(i), float(PCF_SAMPLES), phi).mul(penumbra) - lit = lit.add(shadowCompare(shadowCoord.xy.add(offset), receiverDepth)) - } - lit = lit.div(PCF_SAMPLES) + // 3. Variable-radius PCF with the same rotated Vogel disk. + let lit: any = float(0) + for (let i = 0; i < PCF_SAMPLES; i++) { + const offset = vogelDiskSample(float(i), float(PCF_SAMPLES), phi).mul(penumbra) + lit = lit.add(shadowCompare(shadowCoord.xy.add(offset), receiverDepth)) + } + lit = lit.div(PCF_SAMPLES) - // Fully lit when the blocker search found nothing (early-out semantics - // without branching). - return blockerCount.lessThanEqual(0).select(float(1), lit) - }, -) + // Fully lit when the blocker search found nothing (early-out semantics + // without branching). + return blockerCount.lessThanEqual(0).select(float(1), lit) +}) From 86bc80f5b7803e44c810ee05ec40edfd5030f3a3 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 9 Jul 2026 20:21:47 -0400 Subject: [PATCH 08/16] feat(viewer): laptop-budget SSGI + seamless world-space horizon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SSGI interactive: 1 slice × 6 steps, GI bounce OFF (denoised AO only) — 2 slices was a thermal problem on laptops; the bounce moves to SSGI_BAKE_PARAMS (3×8, gi 1) for one-shot renders - Horizon disc dissolve: albedo fades to black while emissive fades to the background colour, so the far end IS the backdrop (no lit-vs-flat seam); backdrop gradient graded with the same transform as the scene - Sky gradient is now world-space: per-pixel view ray reconstructed from the scene camera matrices, sky above the true horizon (dir.y 0→0.35), pure background below — aligns with the disc at any camera angle - Studio theme: ground #e9e7e2 / horizon #fbfbfa / sky #dde7ef so the fade is actually visible against the white void Co-Authored-By: Claude Fable 5 --- packages/nodes/src/site/renderer.tsx | 14 ++-- .../src/components/viewer/post-processing.tsx | 65 +++++++++++++------ packages/viewer/src/lib/scene-themes.ts | 6 +- 3 files changed, 58 insertions(+), 27 deletions(-) diff --git a/packages/nodes/src/site/renderer.tsx b/packages/nodes/src/site/renderer.tsx index 1adcb2e41..fcc14a33b 100644 --- a/packages/nodes/src/site/renderer.tsx +++ b/packages/nodes/src/site/renderer.tsx @@ -109,12 +109,16 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { const material = new MeshLambertNodeMaterial({ color: bgColor }) const center = vec2(fadeBounds.cx, fadeBounds.cz) const dist = positionWorld.xz.sub(center).length() - const fade = smoothstep( - float(fadeBounds.radius * 1.05), - float(fadeBounds.radius * 5), - dist, + const fade = smoothstep(float(fadeBounds.radius * 1.05), float(fadeBounds.radius * 5), dist) + // Dissolve, not tint: the albedo (lighting response, incl. shadows) fades + // to black while an emissive term fades up to the raw background colour — + // so the far end is literally the backdrop, with no lit-vs-flat seam. + material.colorNode = mix(color(bgColor), color('#000000'), fade) + ;(material as unknown as { emissiveNode: unknown }).emissiveNode = mix( + color('#000000'), + color(backgroundColor), + fade, ) - material.colorNode = mix(color(bgColor), color(backgroundColor), fade) material.polygonOffset = true material.polygonOffsetFactor = 2 material.polygonOffsetUnits = 2 diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index 3fdd43c0c..8b565566f 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -1,6 +1,6 @@ import { useFrame, useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Color, Layers, type Object3D, UnsignedByteType } from 'three' +import { Color, Layers, Matrix4, type Object3D, UnsignedByteType } from 'three' import { ssgi } from 'three/addons/tsl/display/SSGINode.js' import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js' import { @@ -46,14 +46,14 @@ export const GRADE_PARAMS = { // SSGI Parameters - adjust these to fine-tune global illumination and ambient occlusion export const SSGI_PARAMS = { enabled: true, - sliceCount: 2, + sliceCount: 1, stepCount: 6, radius: 1.6, expFactor: 1.5, thickness: 0.5, backfaceLighting: 0.5, aoIntensity: 1.5, - giIntensity: 1, + giIntensity: 0, useLinearThickness: false, useScreenSpaceSampling: true, useTemporalFiltering: false, @@ -65,6 +65,7 @@ export const SSGI_BAKE_PARAMS = { ...SSGI_PARAMS, sliceCount: 3, stepCount: 8, + giIntensity: 1, } // Diagnostic toggles for thermal A/B testing. Add `?disable=ao,denoise,outline,postFx` @@ -171,6 +172,11 @@ const PostProcessingPasses = ({ const bgSkyUniform = useRef(uniform(new Color(initSky))) const bgSkyCurrent = useRef(new Color(initSky)) const bgSkyTarget = useRef(new Color()) + // Scene-camera matrices for the backdrop: the pipeline's fullscreen quad has + // its own camera, so the sky gradient reconstructs each pixel's world-space + // view ray from these to find the true horizon (dir.y = 0). + const camProjInvUniform = useRef(uniform(new Matrix4())) + const camWorldUniform = useRef(uniform(new Matrix4())) // Ink-line colour follows the scene-theme background luminance (dark lines on // light scenes, light on dark), refreshed each frame like the background. @@ -439,12 +445,14 @@ const PostProcessingPasses = ({ denoisePass.index.value = 0 denoisePass.radius.value = 5 ao = (denoisePass as any).r - // The GI bounce is composited additively, so its sampling noise reads - // as grain on lit surfaces — denoise it like the AO. - const giDenoise = denoise(vec4(gi, float(1)), scenePassDepth, sceneNormal, camera) - giDenoise.index.value = 1 - giDenoise.radius.value = 5 - gi = (giDenoise as any).rgb + if (SSGI_PARAMS.giIntensity > 0) { + // The GI bounce is composited additively, so its sampling noise + // reads as grain on lit surfaces — denoise it like the AO. + const giDenoise = denoise(vec4(gi, float(1)), scenePassDepth, sceneNormal, camera) + giDenoise.index.value = 1 + giDenoise.radius.value = 5 + gi = (giDenoise as any).rgb + } } else { // Diagnostic path: feed raw noisy SSGI AO straight through. Will // look grainy — that's the point, it isolates denoise cost. @@ -477,13 +485,13 @@ const PostProcessingPasses = ({ // Scene-referred grade (contrast around mid-gray + saturation) before the // pipeline's output tone mapping. Kept out of solid/schematic shading so - // the flat presets stay exact. + // the flat presets stay exact. The same transform is applied to the + // backdrop below so geometry that fades to the background colour (the + // horizon disc) matches it exactly. + const gradeRgb = (rgb: any) => + saturation(rgb.div(0.18).pow(vec3(GRADE_PARAMS.contrast)).mul(0.18), GRADE_PARAMS.saturation) if (shading === 'rendered') { - const gradedRgb = saturation( - sceneColor.rgb.div(0.18).pow(vec3(GRADE_PARAMS.contrast)).mul(0.18), - GRADE_PARAMS.saturation, - ) - sceneColor = vec4(gradedRgb, sceneColor.a) + sceneColor = vec4(gradeRgb(sceneColor.rgb), sceneColor.a) } // Single merged outline node: one shared depth pass for both selected + hovered groups. @@ -532,11 +540,28 @@ const PostProcessingPasses = ({ // compressed into the upper half so everything below mid-screen is pure // horizon colour — the infinite-ground disc fades to that same colour, // so the two meet seamlessly wherever the horizon lands. - const bgGradient = mix( - bgSkyUniform.current, + // World-space view ray per pixel → sky above the true horizon + // (dir.y = 0), pure background at/below it. The horizon disc fades to + // the same background colour, so backdrop and ground meet seamlessly + // exactly where the disc vanishes. + const ndc = vec4( + screenUV.x.mul(2).sub(1), + float(1).sub(screenUV.y).mul(2).sub(1), + 1, + 1, + ) as any + const viewRay = (camProjInvUniform.current as any).mul(ndc) + const worldDir = (camWorldUniform.current as any) + .mul(vec4(viewRay.xyz, 0)) + .xyz.normalize() + let bgGradient = mix( bgUniform.current, - smoothstep(float(0), float(0.5), screenUV.y), - ) + bgSkyUniform.current, + smoothstep(float(0.0), float(0.35), worldDir.y), + ) as any + if (shading === 'rendered') { + bgGradient = gradeRgb(bgGradient) + } const composited = mix(bgGradient, compositeWithOutlines.rgb, contentAlpha) // Editor overlays painted on top by their own alpha — they never get inked, // AO'd, or outlined, and always read crisp regardless of scene depth. @@ -622,6 +647,8 @@ const PostProcessingPasses = ({ bgSkyTarget.current.set(bgTheme.backgroundSky ?? bgTheme.background) bgSkyCurrent.current.lerp(bgSkyTarget.current, Math.min(delta, 0.1) * 4) bgSkyUniform.current.value.copy(bgSkyCurrent.current) + camProjInvUniform.current.value.copy(camera.projectionMatrixInverse) + camWorldUniform.current.value.copy(camera.matrixWorld) // Ink colour follows the (lerping) background luminance — snaps dark↔light. inkColorUniform.current.value.set(edgeColorFor(`#${bgCurrent.current.getHexString()}`)) diff --git a/packages/viewer/src/lib/scene-themes.ts b/packages/viewer/src/lib/scene-themes.ts index 73525fa33..7355740f1 100644 --- a/packages/viewer/src/lib/scene-themes.ts +++ b/packages/viewer/src/lib/scene-themes.ts @@ -33,9 +33,9 @@ export const SCENE_THEMES: SceneTheme[] = [ id: 'studio', name: 'Studio', appearance: 'light', - background: '#ffffff', - backgroundSky: '#e7edf3', - ground: '#f4f4f2', + background: '#fbfbfa', + backgroundSky: '#dde7ef', + ground: '#e9e7e2', ambient: { color: '#ffffff', intensity: 0.15 }, hemi: { sky: '#ffffff', ground: '#aaa49a', intensity: 0.45 }, lights: [ From 84832c84bc63c7570fdeb9e384e54ae98a2aa1a2 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 9 Jul 2026 20:37:40 -0400 Subject: [PATCH 09/16] fix(viewer): kill shadow grain (PCSS opt-in only) and the inked horizon line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The eye-level ground grain was measured (high-pass σ on a flat patch: 2.95 baseline → 1.52 shadows-off → 2.49 ao-off): PCSS's per-pixel IGN dither was the dominant source, and it can't be fixed within a laptop budget without TAA. Interactive shadows revert to the renderer's PCFSoft (clean, cheap); PCSS stays wired behind ?enable=pcss for experiments and future bake-time use - The horizon 'line' was the ink pass edge-detecting the ground disc's depth silhouette against the backdrop. Ink now fades with raw depth (full below ~150 m, gone past ~350 m) — near silhouettes keep their SketchUp line, the horizon dissolves cleanly Co-Authored-By: Claude Fable 5 --- packages/viewer/src/components/viewer/lights.tsx | 15 ++++++++++++--- .../src/components/viewer/post-processing.tsx | 9 +++++---- packages/viewer/src/lib/ink-edges.ts | 8 +++++++- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/packages/viewer/src/components/viewer/lights.tsx b/packages/viewer/src/components/viewer/lights.tsx index 10075b236..aa64246c4 100644 --- a/packages/viewer/src/components/viewer/lights.tsx +++ b/packages/viewer/src/components/viewer/lights.tsx @@ -13,6 +13,17 @@ import { PCSSShadowFilter } from '../../lib/pcss' import { getSceneTheme } from '../../lib/scene-themes' import useViewer from '../../store/use-viewer' +// Opt-in PCSS (`?enable=pcss`): contact-hardening penumbra, but its per-pixel +// dither needs a TAA to resolve and ~3× the taps to smooth — too grainy/hot +// for the interactive default. Kept wired for experiments and bake-time use. +const PCSS_ENABLED = + typeof window !== 'undefined' && + new Set( + (new URLSearchParams(window.location.search).get('enable') ?? '') + .split(',') + .map((s) => s.trim()), + ).has('pcss') + // Diagnostic toggle: `?disable=shadows` skips the shadow-map render pass // (which doubles draw calls for every shadow-casting mesh) so you can // isolate how much of the baseline GPU cost is shadows vs. raw geometry. @@ -153,9 +164,7 @@ export function Lights() { // Resize the ortho frustum to the fitted bounds. The shadow camera is // the below. const cam = light.shadow?.camera as THREE.OrthographicCamera | undefined - if (light.shadow && (light.shadow as any).filterNode == null) { - // PCSS: contact-hardening penumbra instead of the renderer's - // uniform PCF blur. Assigned once per shadow instance. + if (PCSS_ENABLED && light.shadow && (light.shadow as any).filterNode == null) { ;(light.shadow as any).filterNode = PCSSShadowFilter } if (cam) { diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index 8b565566f..31bb05b59 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -489,7 +489,10 @@ const PostProcessingPasses = ({ // backdrop below so geometry that fades to the background colour (the // horizon disc) matches it exactly. const gradeRgb = (rgb: any) => - saturation(rgb.div(0.18).pow(vec3(GRADE_PARAMS.contrast)).mul(0.18), GRADE_PARAMS.saturation) + saturation( + rgb.div(0.18).pow(vec3(GRADE_PARAMS.contrast)).mul(0.18), + GRADE_PARAMS.saturation, + ) if (shading === 'rendered') { sceneColor = vec4(gradeRgb(sceneColor.rgb), sceneColor.a) } @@ -551,9 +554,7 @@ const PostProcessingPasses = ({ 1, ) as any const viewRay = (camProjInvUniform.current as any).mul(ndc) - const worldDir = (camWorldUniform.current as any) - .mul(vec4(viewRay.xyz, 0)) - .xyz.normalize() + const worldDir = (camWorldUniform.current as any).mul(vec4(viewRay.xyz, 0)).xyz.normalize() let bgGradient = mix( bgUniform.current, bgSkyUniform.current, diff --git a/packages/viewer/src/lib/ink-edges.ts b/packages/viewer/src/lib/ink-edges.ts index 19ea36fb9..d8798f4b7 100644 --- a/packages/viewer/src/lib/ink-edges.ts +++ b/packages/viewer/src/lib/ink-edges.ts @@ -66,6 +66,12 @@ export function inkedEdges({ const nL = colorToDirection(normalTex.sample(uvN.sub(vec2(px.x, 0)))).normalize() const nU = colorToDirection(normalTex.sample(uvN.add(vec2(0, px.y)))).normalize() const nD = colorToDirection(normalTex.sample(uvN.sub(vec2(0, px.y)))).normalize() + // Ink is a near/mid-field affordance: fade it out with raw depth so the + // horizon (the infinite ground disc vanishing against the backdrop) and + // other far-field depth cliffs never draw a line across the sky junction. + // ~full ink below ≈150 m, none beyond ≈350 m (perspective near 0.1/far 1000). + const distanceFade = float(1).sub(smoothstep(float(0.9994), float(0.9998), dC)) + const nDiff = max( max(float(1).sub(nC.dot(nR)), float(1).sub(nC.dot(nL))), max(float(1).sub(nC.dot(nU)), float(1).sub(nC.dot(nD))), @@ -74,6 +80,6 @@ export function inkedEdges({ // TSL's typed overloads are finicky across versions; the runtime is proven in // the aesthetic sandbox, so cast at the mask/mix boundary. - const edgeMask: any = min(max(depthEdge, normalEdge).mul(opacity), float(1)) + const edgeMask: any = min(max(depthEdge, normalEdge).mul(opacity).mul(distanceFade), float(1)) return (mix as any)(sceneRgb, inkColor, edgeMask) } From b71a6f4c042c0969eedb476a1ec15a02a418a6c2 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 9 Jul 2026 20:47:42 -0400 Subject: [PATCH 10/16] feat(viewer): soft blue skies for light presets, slab coplanarity epsilon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backgroundSky retuned: studio/paper/blueprint/verdant get a soft blue zenith (overcast keeps a bluish gray — it's overcast; mediterranean/ sunset were already blue; dark themes untouched) - Slabs duplicated at the exact same position z-fight and no camera near/far tuning can separate identical depths; each slab mesh now gets a deterministic sub-3mm lift hashed from its node id. Render-only — node data, snapping and measurements untouched. (Long-term fix is reversedDepthBuffer; parked in plans with the depth-consumer audit.) Co-Authored-By: Claude Fable 5 --- packages/viewer/src/lib/scene-themes.ts | 10 +++++----- packages/viewer/src/systems/slab/slab-system.tsx | 15 ++++++++++++++- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/viewer/src/lib/scene-themes.ts b/packages/viewer/src/lib/scene-themes.ts index 7355740f1..c6398c8fe 100644 --- a/packages/viewer/src/lib/scene-themes.ts +++ b/packages/viewer/src/lib/scene-themes.ts @@ -34,7 +34,7 @@ export const SCENE_THEMES: SceneTheme[] = [ name: 'Studio', appearance: 'light', background: '#fbfbfa', - backgroundSky: '#dde7ef', + backgroundSky: '#b6cfe7', ground: '#e9e7e2', ambient: { color: '#ffffff', intensity: 0.15 }, hemi: { sky: '#ffffff', ground: '#aaa49a', intensity: 0.45 }, @@ -56,7 +56,7 @@ export const SCENE_THEMES: SceneTheme[] = [ name: 'Paper', appearance: 'light', background: '#ede9df', - backgroundSky: '#ded7c6', + backgroundSky: '#c0d2e4', ground: '#e7e1d3', ambient: { color: '#fff9eb', intensity: 0.55 }, hemi: { sky: '#fff5d9', ground: '#c2b89c', intensity: 0.35 }, @@ -100,7 +100,7 @@ export const SCENE_THEMES: SceneTheme[] = [ name: 'Overcast', appearance: 'light', background: '#e6e7e6', - backgroundSky: '#cfd3d2', + backgroundSky: '#c3ccd6', ground: '#dadcd9', ambient: { color: '#eef0ef', intensity: 1.1 }, hemi: { sky: '#f4f5f3', ground: '#bcbfbb', intensity: 0.9 }, @@ -119,7 +119,7 @@ export const SCENE_THEMES: SceneTheme[] = [ name: 'Blueprint', appearance: 'light', background: '#dde6ef', - backgroundSky: '#bfd2e6', + backgroundSky: '#a5c4e2', ground: '#c9d6e6', ambient: { color: '#cfdcec', intensity: 0.7 }, hemi: { sky: '#dfeaf6', ground: '#5b6b80', intensity: 0.55 }, @@ -207,7 +207,7 @@ export const SCENE_THEMES: SceneTheme[] = [ name: 'Verdant', appearance: 'light', background: '#d6e4d2', - backgroundSky: '#bcd4c6', + backgroundSky: '#aecde0', ground: '#c7d6b4', ambient: { color: '#e3efdd', intensity: 0.5 }, hemi: { sky: '#cfe6cf', ground: '#8ea06f', intensity: 0.65 }, diff --git a/packages/viewer/src/systems/slab/slab-system.tsx b/packages/viewer/src/systems/slab/slab-system.tsx index 61b280637..8ee82304e 100644 --- a/packages/viewer/src/systems/slab/slab-system.tsx +++ b/packages/viewer/src/systems/slab/slab-system.tsx @@ -74,8 +74,21 @@ function updateSlabGeometry(node: SlabNode, mesh: THREE.Mesh) { // For negative elevation, shift the mesh down so the top face sits at Y=elevation // rather than at Y=0. Positive elevation stays at Y=0 (slab sits at floor level). + // A deterministic sub-3mm per-node lift breaks the coplanarity of slabs + // duplicated at the exact same position — identical depths z-fight, and no + // camera near/far tuning can separate them. Render-only: node data, + // snapping and measurements are untouched. const elevation = node.elevation ?? 0.05 - mesh.position.y = elevation < 0 ? elevation : 0 + mesh.position.y = (elevation < 0 ? elevation : 0) + coplanarityEpsilon(node.id) +} + +// Stable id hash → 0..2.7 mm in 0.3 mm steps. +function coplanarityEpsilon(id: string): number { + let hash = 0 + for (let i = 0; i < id.length; i++) { + hash = (hash * 31 + id.charCodeAt(i)) | 0 + } + return (Math.abs(hash) % 10) * 0.0003 } /** From 5009dc078675a82ef1dd644573fa230b9090764a Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 13 Jul 2026 09:15:44 -0400 Subject: [PATCH 11/16] fix(viewer): walk back perf-costly rendering-pass pieces, soften dark-theme ink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on the rendering pass: - SSGI back to main's params (1 slice / 4 steps / r1, AO-only) everywhere, including thumbnails — SSGI_BAKE_PARAMS removed. Kills the added AO grain and the extra per-frame cost; bake workers also stop paying for heavier GI. - Shadows stay visible via plain knobs only (intensity 0.9, radius 4): the custom TSL PCSS filter is gone, shadow map back to 1024, filter back to PCF (r184's Vogel-disk PCF respects radius, so edges stay soft). - Night/twilight ink edges: colour now derived from the theme background (lifted toward white) instead of a near-white constant, and dark scenes run the ink at 70% alpha — no more glowing wireframe on dark backdrops. - Snapshots: the `transparent` capture flag is now honored — preset/item captures keep their alpha, while studio renders and project thumbnails composite the theme background + sky gradient (same world-ray math as the viewport backdrop, uniform-driven so the cached pipeline serves both). Co-Authored-By: Claude Fable 5 --- .../components/editor/thumbnail-generator.tsx | 85 +++++++++++++---- .../viewer/src/components/viewer/index.tsx | 2 +- .../viewer/src/components/viewer/lights.tsx | 17 +--- .../src/components/viewer/post-processing.tsx | 35 +++---- packages/viewer/src/index.ts | 1 - packages/viewer/src/lib/edge-style.ts | 33 +++++-- packages/viewer/src/lib/ink-edges.ts | 3 +- packages/viewer/src/lib/pcss.ts | 94 ------------------- 8 files changed, 110 insertions(+), 160 deletions(-) delete mode 100644 packages/viewer/src/lib/pcss.ts diff --git a/packages/editor/src/components/editor/thumbnail-generator.tsx b/packages/editor/src/components/editor/thumbnail-generator.tsx index 88363199e..188540396 100644 --- a/packages/editor/src/components/editor/thumbnail-generator.tsx +++ b/packages/editor/src/components/editor/thumbnail-generator.tsx @@ -3,7 +3,8 @@ import { emitter, sceneRegistry } from '@pascal-app/core' import { GRID_LAYER, - SSGI_BAKE_PARAMS, + getSceneTheme, + SSGI_PARAMS, snapLevelsToTruePositions, useViewer, } from '@pascal-app/viewer' @@ -21,11 +22,15 @@ import { diffuseColor, directionToColor, float, + mix, mrt, normalView, output, pass, sample, + screenUV, + smoothstep, + uniform, vec4, } from 'three/tsl' import { RenderPipeline, RenderTarget, type WebGPURenderer } from 'three/webgpu' @@ -59,6 +64,16 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro const pipelineRef = useRef(null) const renderTargetRef = useRef(null) + // Backdrop compositing for scene snapshots (studio renders, project + // thumbnails): theme background + sky gradient, same world-ray math as the + // viewport backdrop in viewer's post-processing. Uniform-driven so the one + // cached pipeline serves both opaque and transparent (preset/item) captures. + const bgColorUniform = useRef(uniform(new THREE.Color('#ffffff'))) + const bgSkyUniform = useRef(uniform(new THREE.Color('#ffffff'))) + const bgProjInvUniform = useRef(uniform(new THREE.Matrix4())) + const bgCamWorldUniform = useRef(uniform(new THREE.Matrix4())) + const bgMixUniform = useRef(uniform(1)) + useEffect(() => { onThumbnailCaptureRef.current = onThumbnailCapture }, [onThumbnailCapture]) @@ -99,17 +114,17 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro const sceneNormal = sample((uv) => colorToDirection(scenePassNormal.sample(uv))) const giPass = ssgi(scenePassColor, scenePassDepth, sceneNormal, cam as any) - giPass.sliceCount.value = SSGI_BAKE_PARAMS.sliceCount - giPass.stepCount.value = SSGI_BAKE_PARAMS.stepCount - giPass.radius.value = SSGI_BAKE_PARAMS.radius - giPass.expFactor.value = SSGI_BAKE_PARAMS.expFactor - giPass.thickness.value = SSGI_BAKE_PARAMS.thickness - giPass.backfaceLighting.value = SSGI_BAKE_PARAMS.backfaceLighting - giPass.aoIntensity.value = SSGI_BAKE_PARAMS.aoIntensity - giPass.giIntensity.value = SSGI_BAKE_PARAMS.giIntensity - giPass.useLinearThickness.value = SSGI_BAKE_PARAMS.useLinearThickness - giPass.useScreenSpaceSampling.value = SSGI_BAKE_PARAMS.useScreenSpaceSampling - giPass.useTemporalFiltering = SSGI_BAKE_PARAMS.useTemporalFiltering + 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() const aoAsRgb = vec4(giTexture.a, giTexture.a, giTexture.a, float(1)) @@ -118,7 +133,31 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro denoisePass.radius.value = 4 const ao = (denoisePass as any).r - const finalOutput = vec4(scenePassColor.rgb.mul(ao), scenePassColor.a) + const sceneRgb = scenePassColor.rgb.mul(ao) + + // Per-pixel world ray from the capture camera → sky gradient above the + // horizon (dir.y = 0), flat background below — mirrors the viewport + // backdrop. bgMix 0 bypasses it and keeps the capture transparent. + const ndc = vec4( + screenUV.x.mul(2).sub(1), + float(1).sub(screenUV.y).mul(2).sub(1), + 1, + 1, + ) as any + const viewRay = (bgProjInvUniform.current as any).mul(ndc) + const worldDir = (bgCamWorldUniform.current as any) + .mul(vec4(viewRay.xyz, 0)) + .xyz.normalize() + const bgGradient = mix( + bgColorUniform.current, + bgSkyUniform.current, + smoothstep(float(0.0), float(0.35), worldDir.y), + ) + const alpha = scenePassColor.a + const finalOutput = vec4( + mix(sceneRgb, mix(bgGradient, sceneRgb, alpha), bgMixUniform.current), + mix(alpha, float(1), bgMixUniform.current), + ) // FXAA requires a texture node as input; convertToTexture renders finalOutput // into an intermediate RT so FXAA can sample it with neighbour UV offsets. @@ -157,6 +196,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro captureMode?: 'standard' | 'viewport' | 'area', cropRegion?: { x: number; y: number; width: number; height: number }, standardSize?: { w: number; h: number }, + transparent = false, ) => { const standardW = standardSize?.w ?? THUMBNAIL_WIDTH const standardH = standardSize?.h ?? THUMBNAIL_HEIGHT @@ -181,6 +221,17 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro const { width, height } = gl.domElement thumbnailCamera.aspect = width / height thumbnailCamera.updateProjectionMatrix() + // The capture camera never joins the scene graph, so its matrixWorld + // is only refreshed by the render itself — too late for the backdrop + // uniforms below. + thumbnailCamera.updateMatrixWorld() + + const theme = getSceneTheme(useViewer.getState().sceneTheme) + bgColorUniform.current.value.set(theme.background) + bgSkyUniform.current.value.set(theme.backgroundSky ?? theme.background) + bgProjInvUniform.current.value.copy(thumbnailCamera.projectionMatrixInverse) + bgCamWorldUniform.current.value.copy(thumbnailCamera.matrixWorld) + bgMixUniform.current.value = transparent ? 0 : 1 // Capture camera data for snapshot storage const pos = mainCamera.position @@ -478,10 +529,9 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro cropRegion?: { x: number; y: number; width: number; height: number } standardSize?: { w: number; h: number } snapLevels?: boolean - // `transparent` is informational here — the render pipeline already - // captures with alpha (see `setClearAlpha(0)` above) — the flag is - // forwarded so future tweaks (suppressing the ground occluder, theme - // background bits) can branch on it without touching the emitter. + // Preset/item captures keep the alpha channel (their thumbnails compose + // onto arbitrary palette backgrounds); scene snapshots — studio renders + // and project thumbnails — composite the theme backdrop + sky. transparent?: boolean }) => { await generate( @@ -489,6 +539,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro event.captureMode, event.cropRegion, event.standardSize, + event.transparent === true, ) } diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index dd9388faf..0153b087e 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -519,7 +519,7 @@ const Viewer = forwardRef(function Viewer( debounce: 100, }} shadows={{ - type: THREE.PCFSoftShadowMap, + type: THREE.PCFShadowMap, enabled: true, }} > diff --git a/packages/viewer/src/components/viewer/lights.tsx b/packages/viewer/src/components/viewer/lights.tsx index aa64246c4..c51cccc1d 100644 --- a/packages/viewer/src/components/viewer/lights.tsx +++ b/packages/viewer/src/components/viewer/lights.tsx @@ -9,21 +9,9 @@ import type { } from 'three/webgpu' import * as THREE from 'three/webgpu' import { SHADOW_ONLY_LAYER } from '../../lib/layers' -import { PCSSShadowFilter } from '../../lib/pcss' import { getSceneTheme } from '../../lib/scene-themes' import useViewer from '../../store/use-viewer' -// Opt-in PCSS (`?enable=pcss`): contact-hardening penumbra, but its per-pixel -// dither needs a TAA to resolve and ~3× the taps to smooth — too grainy/hot -// for the interactive default. Kept wired for experiments and bake-time use. -const PCSS_ENABLED = - typeof window !== 'undefined' && - new Set( - (new URLSearchParams(window.location.search).get('enable') ?? '') - .split(',') - .map((s) => s.trim()), - ).has('pcss') - // Diagnostic toggle: `?disable=shadows` skips the shadow-map render pass // (which doubles draw calls for every shadow-casting mesh) so you can // isolate how much of the baseline GPU cost is shadows vs. raw geometry. @@ -164,9 +152,6 @@ export function Lights() { // Resize the ortho frustum to the fitted bounds. The shadow camera is // the below. const cam = light.shadow?.camera as THREE.OrthographicCamera | undefined - if (PCSS_ENABLED && light.shadow && (light.shadow as any).filterNode == null) { - ;(light.shadow as any).filterNode = PCSSShadowFilter - } if (cam) { // Shadow-caster-only geometry (hidden roofs/levels in cutaway views) // is visible to the shadow pass alone — see lib/shadow-only.ts. @@ -266,7 +251,7 @@ export function Lights() { lightRefs.current[index] = ref }} shadow-bias={-0.002} - shadow-mapSize={[2048, 2048]} + shadow-mapSize={[1024, 1024]} shadow-normalBias={0.3} shadow-radius={4} > diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index 31bb05b59..c8d804d40 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -27,7 +27,7 @@ import { vec4, } from 'three/tsl' import { RenderPipeline, type WebGPURenderer } from 'three/webgpu' -import { edgeColorFor } from '../../lib/edge-style' +import { edgeColorFor, edgeOpacityScaleFor } from '../../lib/edge-style' import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf' import { inkedEdges } from '../../lib/ink-edges' import { GRID_LAYER, OVERLAY_LAYER, SCENE_LAYER, ZONE_LAYER } from '../../lib/layers' @@ -47,8 +47,8 @@ export const GRADE_PARAMS = { export const SSGI_PARAMS = { enabled: true, sliceCount: 1, - stepCount: 6, - radius: 1.6, + stepCount: 4, + radius: 1, expFactor: 1.5, thickness: 0.5, backfaceLighting: 0.5, @@ -59,15 +59,6 @@ export const SSGI_PARAMS = { useTemporalFiltering: false, } -// Heavier SSGI for one-shot renders (thumbnails / bake capture) where frame -// time doesn't matter — the interactive editor stays on SSGI_PARAMS. -export const SSGI_BAKE_PARAMS = { - ...SSGI_PARAMS, - sliceCount: 3, - stepCount: 8, - giIntensity: 1, -} - // Diagnostic toggles for thermal A/B testing. Add `?disable=ao,denoise,outline,postFx` // to the URL (any subset) and reload to skip those passes. Each flag prevents // allocation + per-frame work for that stage, so device temperature deltas @@ -180,7 +171,9 @@ const PostProcessingPasses = ({ // Ink-line colour follows the scene-theme background luminance (dark lines on // light scenes, light on dark), refreshed each frame like the background. + // Dark scenes also scale the ink opacity down (see edge-style.ts). const inkColorUniform = useRef(uniform(new Color(edgeColorFor(initBg)))) + const inkOpacityScaleUniform = useRef(uniform(edgeOpacityScaleFor(initBg))) const zoneLayers = useMemo(() => { const l = new Layers() @@ -435,7 +428,7 @@ const PostProcessingPasses = ({ const giTexture = (giPass as any).getTextureNode() - let gi: any = giPass.rgb + const gi = giPass.rgb let ao: any if (denoiseEnabled) { // DenoiseNode only denoises RGB — alpha is passed through unchanged. @@ -443,16 +436,8 @@ const PostProcessingPasses = ({ 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 = 5 + denoisePass.radius.value = 4 ao = (denoisePass as any).r - if (SSGI_PARAMS.giIntensity > 0) { - // The GI bounce is composited additively, so its sampling noise - // reads as grain on lit surfaces — denoise it like the AO. - const giDenoise = denoise(vec4(gi, float(1)), scenePassDepth, sceneNormal, camera) - giDenoise.index.value = 1 - giDenoise.radius.value = 5 - gi = (giDenoise as any).rgb - } } else { // Diagnostic path: feed raw noisy SSGI AO straight through. Will // look grainy — that's the point, it isolates denoise cost. @@ -477,7 +462,7 @@ const PostProcessingPasses = ({ normalTex: scenePassNormal, inkColor: inkColorUniform.current, radius: inkRadius, - opacity: inkOpacity, + opacity: float(inkOpacity).mul(inkOpacityScaleUniform.current), }), sceneColor.a, ) @@ -651,7 +636,9 @@ const PostProcessingPasses = ({ camProjInvUniform.current.value.copy(camera.projectionMatrixInverse) camWorldUniform.current.value.copy(camera.matrixWorld) // Ink colour follows the (lerping) background luminance — snaps dark↔light. - inkColorUniform.current.value.set(edgeColorFor(`#${bgCurrent.current.getHexString()}`)) + const bgHex = `#${bgCurrent.current.getHexString()}` + inkColorUniform.current.value.set(edgeColorFor(bgHex)) + inkOpacityScaleUniform.current.value = edgeOpacityScaleFor(bgHex) const outliner = useViewer.getState().outliner sanitizeOutlineObjects(outliner.selectedObjects) diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index b8bae1172..fa786bb98 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -38,7 +38,6 @@ export { GlbWalkthroughController } from './components/viewer/glb-walkthrough-co export type { HoverStyle, HoverStyles } from './components/viewer/post-processing' export { DEFAULT_HOVER_STYLES, - SSGI_BAKE_PARAMS, SSGI_PARAMS, } from './components/viewer/post-processing' export { SceneEnvironment } from './components/viewer/scene-environment' diff --git a/packages/viewer/src/lib/edge-style.ts b/packages/viewer/src/lib/edge-style.ts index e0226ece7..db9f7b9dc 100644 --- a/packages/viewer/src/lib/edge-style.ts +++ b/packages/viewer/src/lib/edge-style.ts @@ -2,14 +2,35 @@ // mode. `off`/`soft`/`strong` map to ink intensity in the post-processing pass. export type EdgeMode = 'off' | 'soft' | 'strong' -// Ink line colour follows background luminance — light backgrounds get -// near-black lines, dark backgrounds get near-white. Same rule Mapbox uses for -// label outlines, so edges stay legible across every scene theme. -export function edgeColorFor(background: string): string { +function lumaOf(background: string): number { const hex = background.replace('#', '') const r = Number.parseInt(hex.slice(0, 2), 16) / 255 const g = Number.parseInt(hex.slice(2, 4), 16) / 255 const b = Number.parseInt(hex.slice(4, 6), 16) / 255 - const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b - return luma > 0.5 ? '#1a1d24' : '#dde2eb' + return 0.2126 * r + 0.7152 * g + 0.0722 * b +} + +// Ink line colour follows background luminance — light backgrounds get +// near-black lines (the rule Mapbox uses for label outlines). Dark backgrounds +// get a tint *derived from the background* rather than a near-white constant: +// full-white lines glow harshly against night/twilight scenes, while lifting +// the background's own hue toward white stays legible but reads native to the +// theme. +export function edgeColorFor(background: string): string { + if (lumaOf(background) > 0.5) return '#1a1d24' + const hex = background.replace('#', '') + let out = '#' + for (let i = 0; i < 3; i++) { + const c = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16) / 255 + out += Math.round((c + (1 - c) * 0.42) * 255) + .toString(16) + .padStart(2, '0') + } + return out +} + +// Dark scenes additionally run the ink slightly transparent — even a muted +// tint at full alpha reads as glowing wireframe against a low-luma backdrop. +export function edgeOpacityScaleFor(background: string): number { + return lumaOf(background) > 0.5 ? 1 : 0.7 } diff --git a/packages/viewer/src/lib/ink-edges.ts b/packages/viewer/src/lib/ink-edges.ts index d8798f4b7..3b928b512 100644 --- a/packages/viewer/src/lib/ink-edges.ts +++ b/packages/viewer/src/lib/ink-edges.ts @@ -41,8 +41,9 @@ export function inkedEdges({ // Line thickness in px (the detected band is ~2×radius) and final line // darkness — these are what distinguish soft (thin/faint) from strong // (thick/solid); the edge masks themselves saturate, so a gain wouldn't. + // Opacity may be a TSL node (theme-driven scaling) or a plain number. radius: number - opacity: number + opacity: any sceneRgb: any }) { const px = vec2(1, 1).div(screenSize).mul(radius) diff --git a/packages/viewer/src/lib/pcss.ts b/packages/viewer/src/lib/pcss.ts deleted file mode 100644 index f71bae8ab..000000000 --- a/packages/viewer/src/lib/pcss.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { - Fn, - float, - interleavedGradientNoise, - reference, - screenCoordinate, - texture, - textureLoad, - vec2, - vogelDiskSample, -} from 'three/tsl' - -/** - * PCSS-style sun shadow filter (percentage-closer soft shadows) for the - * WebGPU shadow path, plugged in via `LightShadow.filterNode`. - * - * Contact-hardening: a blocker search estimates the average occluder depth - * around the receiver, the receiver–blocker gap sets the penumbra width, and - * a rotated Vogel-disk PCF filters at that radius. Near contact points the - * radius collapses to a texel (crisp), far from the caster it widens (soft) — - * unlike plain PCF whose blur is uniform everywhere. - * - * The shadow's `radius` property scales the maximum penumbra, so themes/tuning - * keep a single knob. Both loops are fixed-count and unrolled (plain JS loops - * over TSL expressions), sidestepping WGSL uniform-control-flow issues. - */ - -// Penumbra width in shadow-map UV per unit of normalized receiver–blocker -// depth gap. The ortho depth range spans the building bounds, so ~0.02 UV per -// unit reads as a sun-like penumbra; `shadow.radius` multiplies on top. -const PENUMBRA_SCALE = 0.02 -// Blocker search radius in texels — how far to look for occluders. -const BLOCKER_SEARCH_TEXELS = 8 -const BLOCKER_SAMPLES = 8 -const PCF_SAMPLES = 12 -const TWO_PI = 6.28318530718 - -export const PCSSShadowFilter = Fn(({ depthTexture, shadowCoord, shadow, depthLayer }: any) => { - const mapSize: any = reference('mapSize', 'vec2', shadow) - const radius: any = reference('radius', 'float', shadow) - const texelSize = vec2(1).div(mapSize) - - // Raw depth reads use texelFetch (textureLoad) — the sampled path would - // inherit the comparison sampler the PCF taps bind, which WGSL rejects. - const depthAt = (uv: any) => { - const texel = uv.mul(mapSize).toIVec2() - let depth: any = textureLoad(depthTexture, texel) - if (depthTexture.isArrayTexture) depth = depth.depth(depthLayer) - return depth.x - } - const shadowCompare = (uv: any, compare: any) => { - let depth: any = texture(depthTexture, uv) - if (depthTexture.isArrayTexture) depth = depth.depth(depthLayer) - return depth.compare(compare) - } - - const receiverDepth: any = shadowCoord.z - - const phi = interleavedGradientNoise(screenCoordinate.xy).mul(TWO_PI) - - // 1. Blocker search: average depth of occluders inside the search disk. - const searchRadius = texelSize.x.mul(BLOCKER_SEARCH_TEXELS) - let blockerSum: any = float(0) - let blockerCount: any = float(0) - for (let i = 0; i < BLOCKER_SAMPLES; i++) { - const offset = vogelDiskSample(float(i), float(BLOCKER_SAMPLES), phi).mul(searchRadius) - const sampleDepth = depthAt(shadowCoord.xy.add(offset)) - const isBlocker = sampleDepth.lessThan(receiverDepth).toFloat() - blockerSum = blockerSum.add(sampleDepth.mul(isBlocker)) - blockerCount = blockerCount.add(isBlocker) - } - const avgBlocker = blockerSum.div(blockerCount.max(1)) - - // 2. Penumbra estimate: receiver–blocker gap (parallel sun rays) scaled by - // the shadow's radius knob, clamped between crisp (1 texel) and the search - // window so the PCF disk never outruns the blocker estimate. - const penumbra = receiverDepth - .sub(avgBlocker) - .mul(PENUMBRA_SCALE) - .mul(radius) - .clamp(texelSize.x, searchRadius) - - // 3. Variable-radius PCF with the same rotated Vogel disk. - let lit: any = float(0) - for (let i = 0; i < PCF_SAMPLES; i++) { - const offset = vogelDiskSample(float(i), float(PCF_SAMPLES), phi).mul(penumbra) - lit = lit.add(shadowCompare(shadowCoord.xy.add(offset), receiverDepth)) - } - lit = lit.div(PCF_SAMPLES) - - // Fully lit when the blocker search found nothing (early-out semantics - // without branching). - return blockerCount.lessThanEqual(0).select(float(1), lit) -}) From 5744937c2456de21b19dcc86bcdd0a7a0a26ebc9 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 13 Jul 2026 09:36:58 -0400 Subject: [PATCH 12/16] =?UTF-8?q?feat(viewer,nodes):=20ground=20the=20scen?= =?UTF-8?q?e=20=E2=80=94=20contact=20vignette=20+=20horizon=20haze,=20shad?= =?UTF-8?q?ow=20tune?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups from review: - Shadow intensity 0.9 → 0.75 (read too heavy) and PCF radius 4 → 2: the filter's per-pixel dither spreads with radius, which showed as dots across wide penumbras. Both are free knobs. - New shared backdrop formula (viewer lib/backdrop.ts): background below, theme-derived haze band hugging the horizon (background lifted toward white — faint glow on dark themes), sky above. Used by the post pipeline, the thumbnail pipeline, and the site horizon disc, whose far-field dissolve now evaluates the same gradient per fragment view direction — ground and backdrop converge to identical colours, so no horizon seam from any camera pose. - Contact vignette on the horizon disc: a soft albedo darkening hugging the lot (15%, fading out by ~2.6 lot radii) so the parcel sits on the field instead of floating on it. Albedo-only — never tints the dissolve. Co-Authored-By: Claude Fable 5 --- .../components/editor/thumbnail-generator.tsx | 16 ++++--- packages/nodes/src/site/renderer.tsx | 34 +++++++++++--- .../viewer/src/components/viewer/lights.tsx | 7 +-- .../src/components/viewer/post-processing.tsx | 33 +++++++------ packages/viewer/src/index.ts | 1 + packages/viewer/src/lib/backdrop.ts | 47 +++++++++++++++++++ 6 files changed, 109 insertions(+), 29 deletions(-) create mode 100644 packages/viewer/src/lib/backdrop.ts diff --git a/packages/editor/src/components/editor/thumbnail-generator.tsx b/packages/editor/src/components/editor/thumbnail-generator.tsx index 188540396..7bf620752 100644 --- a/packages/editor/src/components/editor/thumbnail-generator.tsx +++ b/packages/editor/src/components/editor/thumbnail-generator.tsx @@ -2,8 +2,10 @@ import { emitter, sceneRegistry } from '@pascal-app/core' import { + backdropGradient, GRID_LAYER, getSceneTheme, + horizonHazeColor, SSGI_PARAMS, snapLevelsToTruePositions, useViewer, @@ -29,7 +31,6 @@ import { pass, sample, screenUV, - smoothstep, uniform, vec4, } from 'three/tsl' @@ -70,6 +71,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro // cached pipeline serves both opaque and transparent (preset/item) captures. const bgColorUniform = useRef(uniform(new THREE.Color('#ffffff'))) const bgSkyUniform = useRef(uniform(new THREE.Color('#ffffff'))) + const bgHazeUniform = useRef(uniform(new THREE.Color('#ffffff'))) const bgProjInvUniform = useRef(uniform(new THREE.Matrix4())) const bgCamWorldUniform = useRef(uniform(new THREE.Matrix4())) const bgMixUniform = useRef(uniform(1)) @@ -148,11 +150,12 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro const worldDir = (bgCamWorldUniform.current as any) .mul(vec4(viewRay.xyz, 0)) .xyz.normalize() - const bgGradient = mix( - bgColorUniform.current, - bgSkyUniform.current, - smoothstep(float(0.0), float(0.35), worldDir.y), - ) + const bgGradient = backdropGradient({ + dirY: worldDir.y, + background: bgColorUniform.current, + haze: bgHazeUniform.current, + sky: bgSkyUniform.current, + }) const alpha = scenePassColor.a const finalOutput = vec4( mix(sceneRgb, mix(bgGradient, sceneRgb, alpha), bgMixUniform.current), @@ -229,6 +232,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro const theme = getSceneTheme(useViewer.getState().sceneTheme) bgColorUniform.current.value.set(theme.background) bgSkyUniform.current.value.set(theme.backgroundSky ?? theme.background) + bgHazeUniform.current.value.set(horizonHazeColor(theme.background, theme.appearance)) bgProjInvUniform.current.value.copy(thumbnailCamera.projectionMatrixInverse) bgCamWorldUniform.current.value.copy(thumbnailCamera.matrixWorld) bgMixUniform.current.value = transparent ? 0 : 1 diff --git a/packages/nodes/src/site/renderer.tsx b/packages/nodes/src/site/renderer.tsx index fcc14a33b..27f33e570 100644 --- a/packages/nodes/src/site/renderer.tsx +++ b/packages/nodes/src/site/renderer.tsx @@ -9,7 +9,9 @@ import { useScene, } from '@pascal-app/core' import { + backdropGradient, getSceneTheme, + horizonHazeColor, NodeRenderer, unionPolygons, useNodeEvents, @@ -25,7 +27,7 @@ import { Shape, ShapeGeometry, } from 'three' -import { color, float, mix, positionWorld, smoothstep, vec2 } from 'three/tsl' +import { cameraPosition, color, float, mix, positionWorld, smoothstep, vec2 } from 'three/tsl' import { MeshLambertNodeMaterial } from 'three/webgpu' const Y_OFFSET = 0.01 @@ -66,6 +68,11 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { const bgColor = useViewer((state) => getSceneTheme(state.sceneTheme).ground) const backgroundColor = useViewer((state) => getSceneTheme(state.sceneTheme).background) + const skyColor = useViewer((state) => { + const theme = getSceneTheme(state.sceneTheme) + return theme.backgroundSky ?? theme.background + }) + const appearance = useViewer((state) => getSceneTheme(state.sceneTheme).appearance) const livePolygon = useLiveNodeOverrides( (state) => (state.overrides.get(node.id)?.polygon as SiteNode['polygon'] | undefined) ?? null, ) @@ -110,20 +117,35 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { const center = vec2(fadeBounds.cx, fadeBounds.cz) const dist = positionWorld.xz.sub(center).length() const fade = smoothstep(float(fadeBounds.radius * 1.05), float(fadeBounds.radius * 5), dist) + // Contact vignette: a soft darkening that hugs the lot so the parcel + // reads as sitting on the ground instead of floating on an even field. + // Albedo-only — it must not tint the far-field dissolve below. + const halo = float(1) + .sub(smoothstep(float(fadeBounds.radius * 0.95), float(fadeBounds.radius * 2.6), dist)) + .mul(0.15) + material.colorNode = mix(color(bgColor), color('#000000'), fade).mul(float(1).sub(halo)) // Dissolve, not tint: the albedo (lighting response, incl. shadows) fades - // to black while an emissive term fades up to the raw background colour — - // so the far end is literally the backdrop, with no lit-vs-flat seam. - material.colorNode = mix(color(bgColor), color('#000000'), fade) + // to black while an emissive term fades up to the backdrop gradient — the + // exact formula the post pipeline composites (viewer lib/backdrop.ts), + // evaluated with this fragment's view direction, so the far end is + // literally the backdrop (incl. the horizon haze) from any camera pose. + const viewDirY = positionWorld.sub(cameraPosition).normalize().y + const backdrop = backdropGradient({ + dirY: viewDirY, + background: color(backgroundColor), + haze: color(horizonHazeColor(backgroundColor, appearance)), + sky: color(skyColor), + }) ;(material as unknown as { emissiveNode: unknown }).emissiveNode = mix( color('#000000'), - color(backgroundColor), + backdrop, fade, ) material.polygonOffset = true material.polygonOffsetFactor = 2 material.polygonOffsetUnits = 2 return material - }, [bgColor, backgroundColor, fadeBounds]) + }, [bgColor, backgroundColor, skyColor, appearance, fadeBounds]) const horizonGeometry = useMemo(() => { if (!fadeBounds) return null diff --git a/packages/viewer/src/components/viewer/lights.tsx b/packages/viewer/src/components/viewer/lights.tsx index c51cccc1d..33daa7f13 100644 --- a/packages/viewer/src/components/viewer/lights.tsx +++ b/packages/viewer/src/components/viewer/lights.tsx @@ -26,8 +26,9 @@ const SHADOWS_DISABLED = // Shadow darkness for the bright key lights (themes drive most lights past // intensity 1). Runs high so shadowed areas actually lose the sun's // contribution — the ambient/hemisphere/IBL stack provides the fill. The old -// 0.55 clamp leaked 45% of the key light into shadow and flattened interiors. -const MAX_SHADOW_INTENSITY = 0.9 +// 0.55 clamp leaked 45% of the key light into shadow and flattened interiors; +// 0.9 read too heavy in review. +const MAX_SHADOW_INTENSITY = 0.75 // Shadow frustum framing. The frustum is fit to the BUILDING geometry (not the // camera): we union the bounds of all registered scene nodes, fit a sphere, and @@ -253,7 +254,7 @@ export function Lights() { shadow-bias={-0.002} shadow-mapSize={[1024, 1024]} shadow-normalBias={0.3} - shadow-radius={4} + shadow-radius={2} > {light.castShadow && !SHADOWS_DISABLED && shadows ? ( Date: Mon, 13 Jul 2026 10:15:20 -0400 Subject: [PATCH 13/16] fix(nodes): make the contact vignette read on bright themes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fixed 15% albedo cut disappears into the tone mapper's shoulder on themes with strong key lights (studio runs intensity 4), and the dissolve's bright emissive diluted what was left. Scale the vignette with the theme's strongest light (0.13×, clamped at 0.45) and apply the halo to the in-band emissive as well — it zeroes out by 2.6R while the dissolve completes at 5R, so the far field stays the pure backdrop and the horizon seam guarantee holds. Co-Authored-By: Claude Fable 5 --- packages/nodes/src/site/renderer.tsx | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/nodes/src/site/renderer.tsx b/packages/nodes/src/site/renderer.tsx index 27f33e570..e122ec601 100644 --- a/packages/nodes/src/site/renderer.tsx +++ b/packages/nodes/src/site/renderer.tsx @@ -73,6 +73,9 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { return theme.backgroundSky ?? theme.background }) const appearance = useViewer((state) => getSceneTheme(state.sceneTheme).appearance) + const maxLightIntensity = useViewer((state) => + Math.max(1, ...getSceneTheme(state.sceneTheme).lights.map((light) => light.intensity)), + ) const livePolygon = useLiveNodeOverrides( (state) => (state.overrides.get(node.id)?.polygon as SiteNode['polygon'] | undefined) ?? null, ) @@ -119,11 +122,15 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { const fade = smoothstep(float(fadeBounds.radius * 1.05), float(fadeBounds.radius * 5), dist) // Contact vignette: a soft darkening that hugs the lot so the parcel // reads as sitting on the ground instead of floating on an even field. - // Albedo-only — it must not tint the far-field dissolve below. + // The linear cut competes with the tone mapper's shoulder — bright themes + // (studio's key light runs at intensity 4) compress a fixed 15% to almost + // nothing — so the strength scales with the theme's strongest light. + const vignetteStrength = Math.min(0.45, 0.13 * maxLightIntensity) const halo = float(1) .sub(smoothstep(float(fadeBounds.radius * 0.95), float(fadeBounds.radius * 2.6), dist)) - .mul(0.15) - material.colorNode = mix(color(bgColor), color('#000000'), fade).mul(float(1).sub(halo)) + .mul(vignetteStrength) + const haloFactor = float(1).sub(halo) + material.colorNode = mix(color(bgColor), color('#000000'), fade).mul(haloFactor) // Dissolve, not tint: the albedo (lighting response, incl. shadows) fades // to black while an emissive term fades up to the backdrop gradient — the // exact formula the post pipeline composites (viewer lib/backdrop.ts), @@ -136,16 +143,20 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { haze: color(horizonHazeColor(backgroundColor, appearance)), sky: color(skyColor), }) + // The halo also scales the in-band emissive: the dissolve starts at 1.05R, + // so without it the (bright) backdrop dilutes the vignette exactly where + // it should read. halo is 0 past 2.6R while the dissolve completes at 5R, + // so the far field stays the pure backdrop — the seam guarantee holds. ;(material as unknown as { emissiveNode: unknown }).emissiveNode = mix( color('#000000'), backdrop, fade, - ) + ).mul(haloFactor) material.polygonOffset = true material.polygonOffsetFactor = 2 material.polygonOffsetUnits = 2 return material - }, [bgColor, backgroundColor, skyColor, appearance, fadeBounds]) + }, [bgColor, backgroundColor, skyColor, appearance, maxLightIntensity, fadeBounds]) const horizonGeometry = useMemo(() => { if (!fadeBounds) return null From d29c1e09da147d6ed49130ecaa9483d15c6379f5 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 13 Jul 2026 10:55:14 -0400 Subject: [PATCH 14/16] fix(viewer): no more horizon line, sky gradient reaches the horizon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things drew a visible line where the infinite ground met the sky: - SSGI AO grows a band along the geometry↔sky depth cliff (same disease the ink pass had). Fade AO to 1 with raw depth over the ink's ≈150→350 m window, in both the viewport and thumbnail pipelines — AO is a near-field cue, it has no business shading the horizon. - The backdrop's flat haze plateau sat between two ramps, which the eye amplifies into Mach lines. The gradient is now one smooth background→sky ramp crossing the horizon, with the haze applied as an exponential glow peaking exactly at dir.y = 0 — C¹-smooth on both sides, edge-free. The sky ramp also starts at the horizon instead of ~11° up, so the theme's backgroundSky blue actually reads at eye level instead of hiding at the top of the frame. Co-Authored-By: Claude Fable 5 --- .../src/components/editor/thumbnail-generator.tsx | 10 +++++++++- .../src/components/viewer/post-processing.tsx | 12 ++++++++++++ packages/viewer/src/lib/backdrop.ts | 14 +++++++++----- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/packages/editor/src/components/editor/thumbnail-generator.tsx b/packages/editor/src/components/editor/thumbnail-generator.tsx index 7bf620752..7571eb4bf 100644 --- a/packages/editor/src/components/editor/thumbnail-generator.tsx +++ b/packages/editor/src/components/editor/thumbnail-generator.tsx @@ -31,6 +31,7 @@ import { pass, sample, screenUV, + smoothstep, uniform, vec4, } from 'three/tsl' @@ -134,7 +135,14 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro denoisePass.index.value = 0 denoisePass.radius.value = 4 - const ao = (denoisePass as any).r + // Same far-field AO fade as the viewport pipeline — without it the + // horizon picks up a visible AO line in captures. + const aoFarFade = smoothstep( + float(0.9994), + float(0.9998), + scenePassDepth.sample(screenUV).r, + ) + const ao = mix((denoisePass as any).r, float(1), aoFarFade) const sceneRgb = scenePassColor.rgb.mul(ao) // Per-pixel world ray from the capture camera → sky gradient above the diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index 6faa83b97..2b39f6df3 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -20,6 +20,7 @@ import { sample, saturation, screenUV, + smoothstep, time, uniform, vec3, @@ -449,6 +450,17 @@ const PostProcessingPasses = ({ ao = giTexture.a } + // AO is a near/mid-field cue like the ink: fade it out with raw depth + // (same ≈150→350 m window as ink-edges' distanceFade) so the horizon + // disc and the geometry↔sky depth cliff never grow an AO band — that + // band read as a visible line along the horizon. + const aoFarFade = smoothstep( + float(0.9994), + float(0.9998), + scenePassDepth.sample(screenUV).r, + ) + ao = mix(ao, float(1), aoFarFade) + // Composite: scene * AO + diffuse * GI sceneColor = vec4( add(scenePassColor.rgb.mul(ao), add(zonePass.rgb, scenePassDiffuse.rgb.mul(gi))), diff --git a/packages/viewer/src/lib/backdrop.ts b/packages/viewer/src/lib/backdrop.ts index ca768fe21..07dc9893a 100644 --- a/packages/viewer/src/lib/backdrop.ts +++ b/packages/viewer/src/lib/backdrop.ts @@ -1,4 +1,4 @@ -import { mix, smoothstep } from 'three/tsl' +import { abs, exp, mix, smoothstep } from 'three/tsl' /** * Shared backdrop gradient: flat background looking down, an atmospheric haze @@ -22,10 +22,14 @@ export function backdropGradient({ haze: any sky: any }): any { - // Below the horizon: background rising into haze as the ray flattens out. - const below = (mix as any)(background, haze, smoothstep(-0.25, -0.01, dirY)) - // Above: haze dissolving into the sky zenith. - return (mix as any)(below, sky, smoothstep(0.02, 0.35, dirY)) + // One gradient crossing the horizon smoothly, sky reaching down to it — + // a flat plateau sandwiched between two ramps reads as Mach lines, and a + // sky that only starts high leaves eye-level views all-white. + const base = (mix as any)(background, sky, smoothstep(-0.02, 0.25, dirY)) + // Haze as an exponential glow peaking exactly at the horizon: C¹-smooth on + // both sides, so it brightens the junction without ever drawing an edge. + const hazeWeight = exp(abs(dirY).mul(-9)).mul(0.85) + return (mix as any)(base, haze, hazeWeight) } /** From eb0c0d87be9bf1066c2fc8311163eedcb7238d66 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 13 Jul 2026 11:02:30 -0400 Subject: [PATCH 15/16] =?UTF-8?q?feat(viewer):=20three-stop=20sky=20?= =?UTF-8?q?=E2=80=94=20derived=20deep=20zenith,=20warm=20horizon=20haze?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single pale backgroundSky stop read as a white void with blue hiding at the top of the frame. The backdrop is now three derived stops, inZOI-style: - pale theme sky arrives fast (full by ≈8° elevation), - then deepens toward a zenith colour derived per theme in HSL (saturate ×1.5, darken ×0.72 — hue stays the theme's own: blue studio, lavender sunset, near-black night), - horizon haze now lifts toward a warm white (#fff4de) instead of pure white, giving the junction the slight yellow of sun-scattered atmosphere. All derived from the existing backgroundSky/background fields — no theme data changes, and the shared-formula seam guarantee (viewport = captures = horizon disc) carries over. Co-Authored-By: Claude Fable 5 --- .../components/editor/thumbnail-generator.tsx | 4 + packages/nodes/src/site/renderer.tsx | 2 + .../src/components/viewer/post-processing.tsx | 12 ++- packages/viewer/src/index.ts | 2 +- packages/viewer/src/lib/backdrop.ts | 89 +++++++++++++++---- 5 files changed, 90 insertions(+), 19 deletions(-) diff --git a/packages/editor/src/components/editor/thumbnail-generator.tsx b/packages/editor/src/components/editor/thumbnail-generator.tsx index 7571eb4bf..2e2eea8d9 100644 --- a/packages/editor/src/components/editor/thumbnail-generator.tsx +++ b/packages/editor/src/components/editor/thumbnail-generator.tsx @@ -3,6 +3,7 @@ import { emitter, sceneRegistry } from '@pascal-app/core' import { backdropGradient, + deepSkyColor, GRID_LAYER, getSceneTheme, horizonHazeColor, @@ -72,6 +73,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro // cached pipeline serves both opaque and transparent (preset/item) captures. const bgColorUniform = useRef(uniform(new THREE.Color('#ffffff'))) const bgSkyUniform = useRef(uniform(new THREE.Color('#ffffff'))) + const bgSkyDeepUniform = useRef(uniform(new THREE.Color('#ffffff'))) const bgHazeUniform = useRef(uniform(new THREE.Color('#ffffff'))) const bgProjInvUniform = useRef(uniform(new THREE.Matrix4())) const bgCamWorldUniform = useRef(uniform(new THREE.Matrix4())) @@ -163,6 +165,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro background: bgColorUniform.current, haze: bgHazeUniform.current, sky: bgSkyUniform.current, + skyDeep: bgSkyDeepUniform.current, }) const alpha = scenePassColor.a const finalOutput = vec4( @@ -240,6 +243,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro const theme = getSceneTheme(useViewer.getState().sceneTheme) bgColorUniform.current.value.set(theme.background) bgSkyUniform.current.value.set(theme.backgroundSky ?? theme.background) + bgSkyDeepUniform.current.value.set(deepSkyColor(theme.backgroundSky ?? theme.background)) bgHazeUniform.current.value.set(horizonHazeColor(theme.background, theme.appearance)) bgProjInvUniform.current.value.copy(thumbnailCamera.projectionMatrixInverse) bgCamWorldUniform.current.value.copy(thumbnailCamera.matrixWorld) diff --git a/packages/nodes/src/site/renderer.tsx b/packages/nodes/src/site/renderer.tsx index e122ec601..bd3f435d2 100644 --- a/packages/nodes/src/site/renderer.tsx +++ b/packages/nodes/src/site/renderer.tsx @@ -10,6 +10,7 @@ import { } from '@pascal-app/core' import { backdropGradient, + deepSkyColor, getSceneTheme, horizonHazeColor, NodeRenderer, @@ -142,6 +143,7 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { background: color(backgroundColor), haze: color(horizonHazeColor(backgroundColor, appearance)), sky: color(skyColor), + skyDeep: color(deepSkyColor(skyColor)), }) // The halo also scales the in-band emissive: the dissolve starts at 1.05R, // so without it the (bright) backdrop dilutes the vignette exactly where diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index 2b39f6df3..4d1dff83e 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -27,7 +27,7 @@ import { vec4, } from 'three/tsl' import { RenderPipeline, type WebGPURenderer } from 'three/webgpu' -import { backdropGradient, horizonHazeColor } from '../../lib/backdrop' +import { backdropGradient, deepSkyColor, horizonHazeColor } from '../../lib/backdrop' import { edgeColorFor, edgeOpacityScaleFor } from '../../lib/edge-style' import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf' import { inkedEdges } from '../../lib/ink-edges' @@ -164,11 +164,15 @@ const PostProcessingPasses = ({ const bgSkyUniform = useRef(uniform(new Color(initSky))) const bgSkyCurrent = useRef(new Color(initSky)) const bgSkyTarget = useRef(new Color()) - // Horizon haze band (derived from the background — see lib/backdrop.ts). + // Horizon haze band + deep zenith (derived — see lib/backdrop.ts). const initHaze = horizonHazeColor(initBg, initTheme.appearance) const bgHazeUniform = useRef(uniform(new Color(initHaze))) const bgHazeCurrent = useRef(new Color(initHaze)) const bgHazeTarget = useRef(new Color()) + const initSkyDeep = deepSkyColor(initSky) + const bgSkyDeepUniform = useRef(uniform(new Color(initSkyDeep))) + const bgSkyDeepCurrent = useRef(new Color(initSkyDeep)) + const bgSkyDeepTarget = useRef(new Color()) // Scene-camera matrices for the backdrop: the pipeline's fullscreen quad has // its own camera, so the sky gradient reconstructs each pixel's world-space // view ray from these to find the true horizon (dir.y = 0). @@ -558,6 +562,7 @@ const PostProcessingPasses = ({ background: bgUniform.current, haze: bgHazeUniform.current, sky: bgSkyUniform.current, + skyDeep: bgSkyDeepUniform.current, }) if (shading === 'rendered') { bgGradient = gradeRgb(bgGradient) @@ -650,6 +655,9 @@ const PostProcessingPasses = ({ bgHazeTarget.current.set(horizonHazeColor(bgTheme.background, bgTheme.appearance)) bgHazeCurrent.current.lerp(bgHazeTarget.current, Math.min(delta, 0.1) * 4) bgHazeUniform.current.value.copy(bgHazeCurrent.current) + bgSkyDeepTarget.current.set(deepSkyColor(bgTheme.backgroundSky ?? bgTheme.background)) + bgSkyDeepCurrent.current.lerp(bgSkyDeepTarget.current, Math.min(delta, 0.1) * 4) + bgSkyDeepUniform.current.value.copy(bgSkyDeepCurrent.current) camProjInvUniform.current.value.copy(camera.projectionMatrixInverse) camWorldUniform.current.value.copy(camera.matrixWorld) // Ink colour follows the (lerping) background luminance — snaps dark↔light. diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index b8db2fc7f..729adcf9b 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -46,7 +46,7 @@ export { useAssetUrl } from './hooks/use-asset-url' export { useGLTFKTX2 } from './hooks/use-gltf-ktx2' export { useNodeEvents } from './hooks/use-node-events' export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url' -export { backdropGradient, horizonHazeColor } from './lib/backdrop' +export { backdropGradient, deepSkyColor, horizonHazeColor } from './lib/backdrop' // CSG primitives — used by chimney's roof-trim and other kinds whose // geometry subtracts pieces against their host. Lives in viewer // because three-bvh-csg / three-mesh-bvh are viewer-only deps. diff --git a/packages/viewer/src/lib/backdrop.ts b/packages/viewer/src/lib/backdrop.ts index 07dc9893a..87e8c15cf 100644 --- a/packages/viewer/src/lib/backdrop.ts +++ b/packages/viewer/src/lib/backdrop.ts @@ -1,12 +1,13 @@ import { abs, exp, mix, smoothstep } from 'three/tsl' /** - * Shared backdrop gradient: flat background looking down, an atmospheric haze - * band hugging the horizon, sky zenith above. One formula serves the - * post-processing backdrop and the thumbnail pipeline (per-pixel view ray) - * AND the site horizon disc's far-field dissolve (per-fragment view - * direction) — the disc converges to exactly this colour, so ground and - * backdrop meet without a seam from any camera pose. + * Shared backdrop gradient: flat background looking down, a warm haze band + * hugging the horizon, the theme sky arriving just above it and deepening + * toward the zenith. One formula serves the post-processing backdrop and the + * thumbnail pipeline (per-pixel view ray) AND the site horizon disc's + * far-field dissolve (per-fragment view direction) — the disc converges to + * exactly this colour, so ground and backdrop meet without a seam from any + * camera pose. * * `dirY` is the world-space view direction's Y component (horizon = 0). * Colour inputs are TSL nodes (uniforms or literals). @@ -16,34 +17,90 @@ export function backdropGradient({ background, haze, sky, + skyDeep, }: { dirY: any background: any haze: any sky: any + skyDeep: any }): any { - // One gradient crossing the horizon smoothly, sky reaching down to it — - // a flat plateau sandwiched between two ramps reads as Mach lines, and a - // sky that only starts high leaves eye-level views all-white. - const base = (mix as any)(background, sky, smoothstep(-0.02, 0.25, dirY)) + // The pale sky arrives fast (full by ≈8° elevation) so eye-level frames + // actually show it, then deepens toward the zenith for a real gradient — + // a single pale stop reads as a white void with blue "hiding" up top. + let base = (mix as any)(background, sky, smoothstep(-0.02, 0.14, dirY)) + base = (mix as any)(base, skyDeep, smoothstep(0.1, 0.55, dirY)) // Haze as an exponential glow peaking exactly at the horizon: C¹-smooth on // both sides, so it brightens the junction without ever drawing an edge. - const hazeWeight = exp(abs(dirY).mul(-9)).mul(0.85) + const hazeWeight = exp(abs(dirY).mul(-7)).mul(0.9) return (mix as any)(base, haze, hazeWeight) } +// Warm white the haze lifts toward — reads as sun-scattered atmosphere at the +// horizon (the slight yellow inZOI-style skies have) rather than sterile fog. +const HAZE_WARM_WHITE = { r: 255, g: 244, b: 222 } + /** - * Atmospheric haze tint at the horizon: the theme background lifted toward - * white. Light themes get a bright distance glow, dark themes a faint one - * (reads as scattered city light). + * Atmospheric haze tint at the horizon: the theme background lifted toward a + * warm white. Light themes get a bright glow, dark themes a faint one (reads + * as scattered city light). */ export function horizonHazeColor(background: string, appearance: 'light' | 'dark'): string { const amount = appearance === 'dark' ? 0.12 : 0.25 const hex = background.replace('#', '') + const warm = [HAZE_WARM_WHITE.r, HAZE_WARM_WHITE.g, HAZE_WARM_WHITE.b] let out = '#' for (let i = 0; i < 3; i++) { - const c = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16) / 255 - out += Math.round((c + (1 - c) * amount) * 255) + const c = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16) + out += Math.round(c + (warm[i]! - c) * amount) + .toString(16) + .padStart(2, '0') + } + return out +} + +/** + * Zenith colour derived from the theme's sky: saturated and darkened in HSL + * so the hue stays the theme's own (blue studio, lavender sunset, near-black + * night) while the top of the frame gets real colour depth. + */ +export function deepSkyColor(sky: string): string { + const hex = sky.replace('#', '') + const r = Number.parseInt(hex.slice(0, 2), 16) / 255 + const g = Number.parseInt(hex.slice(2, 4), 16) / 255 + const b = Number.parseInt(hex.slice(4, 6), 16) / 255 + + const max = Math.max(r, g, b) + const min = Math.min(r, g, b) + const l = (max + min) / 2 + const d = max - min + let h = 0 + let s = 0 + if (d > 0) { + s = d / (1 - Math.abs(2 * l - 1)) + if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6 + else if (max === g) h = ((b - r) / d + 2) / 6 + else h = ((r - g) / d + 4) / 6 + } + + const s2 = Math.min(1, s * 1.5 + 0.05) + const l2 = l * 0.72 + + const c = (1 - Math.abs(2 * l2 - 1)) * s2 + const x = c * (1 - Math.abs(((h * 6) % 2) - 1)) + const m = l2 - c / 2 + const sector = Math.floor(h * 6) % 6 + const rgb = [ + [c, x, 0], + [x, c, 0], + [0, c, x], + [0, x, c], + [x, 0, c], + [c, 0, x], + ][sector]! + let out = '#' + for (const channel of rgb) { + out += Math.round((channel + m) * 255) .toString(16) .padStart(2, '0') } From f2b7722faaf2500396e2a7eb71f45133450f84bb Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 13 Jul 2026 11:07:35 -0400 Subject: [PATCH 16/16] fix(viewer): horizon haze derives from the sky, hugs the horizon tighter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit White-based haze read as a tall white stripe between ground and sky. Aerial perspective is sky-coloured light with a little sun scatter, so the haze now pulls the theme's backgroundSky toward the warm sun tint (50% light themes, 25% dark) and the glow decay tightens (exp −7→−11, weight 0.9→0.8) — the merge band is skyish, sunish, and half the height, so blue starts right above the ground line. Co-Authored-By: Claude Fable 5 --- .../components/editor/thumbnail-generator.tsx | 4 ++- packages/nodes/src/site/renderer.tsx | 2 +- .../src/components/viewer/post-processing.tsx | 6 +++-- packages/viewer/src/lib/backdrop.ts | 26 +++++++++---------- 4 files changed, 21 insertions(+), 17 deletions(-) diff --git a/packages/editor/src/components/editor/thumbnail-generator.tsx b/packages/editor/src/components/editor/thumbnail-generator.tsx index 2e2eea8d9..159885f8b 100644 --- a/packages/editor/src/components/editor/thumbnail-generator.tsx +++ b/packages/editor/src/components/editor/thumbnail-generator.tsx @@ -244,7 +244,9 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro bgColorUniform.current.value.set(theme.background) bgSkyUniform.current.value.set(theme.backgroundSky ?? theme.background) bgSkyDeepUniform.current.value.set(deepSkyColor(theme.backgroundSky ?? theme.background)) - bgHazeUniform.current.value.set(horizonHazeColor(theme.background, theme.appearance)) + bgHazeUniform.current.value.set( + horizonHazeColor(theme.backgroundSky ?? theme.background, theme.appearance), + ) bgProjInvUniform.current.value.copy(thumbnailCamera.projectionMatrixInverse) bgCamWorldUniform.current.value.copy(thumbnailCamera.matrixWorld) bgMixUniform.current.value = transparent ? 0 : 1 diff --git a/packages/nodes/src/site/renderer.tsx b/packages/nodes/src/site/renderer.tsx index bd3f435d2..ca4e251ad 100644 --- a/packages/nodes/src/site/renderer.tsx +++ b/packages/nodes/src/site/renderer.tsx @@ -141,7 +141,7 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { const backdrop = backdropGradient({ dirY: viewDirY, background: color(backgroundColor), - haze: color(horizonHazeColor(backgroundColor, appearance)), + haze: color(horizonHazeColor(skyColor, appearance)), sky: color(skyColor), skyDeep: color(deepSkyColor(skyColor)), }) diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index 4d1dff83e..81c232bd4 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -165,7 +165,7 @@ const PostProcessingPasses = ({ const bgSkyCurrent = useRef(new Color(initSky)) const bgSkyTarget = useRef(new Color()) // Horizon haze band + deep zenith (derived — see lib/backdrop.ts). - const initHaze = horizonHazeColor(initBg, initTheme.appearance) + const initHaze = horizonHazeColor(initSky, initTheme.appearance) const bgHazeUniform = useRef(uniform(new Color(initHaze))) const bgHazeCurrent = useRef(new Color(initHaze)) const bgHazeTarget = useRef(new Color()) @@ -652,7 +652,9 @@ const PostProcessingPasses = ({ bgSkyTarget.current.set(bgTheme.backgroundSky ?? bgTheme.background) bgSkyCurrent.current.lerp(bgSkyTarget.current, Math.min(delta, 0.1) * 4) bgSkyUniform.current.value.copy(bgSkyCurrent.current) - bgHazeTarget.current.set(horizonHazeColor(bgTheme.background, bgTheme.appearance)) + bgHazeTarget.current.set( + horizonHazeColor(bgTheme.backgroundSky ?? bgTheme.background, bgTheme.appearance), + ) bgHazeCurrent.current.lerp(bgHazeTarget.current, Math.min(delta, 0.1) * 4) bgHazeUniform.current.value.copy(bgHazeCurrent.current) bgSkyDeepTarget.current.set(deepSkyColor(bgTheme.backgroundSky ?? bgTheme.background)) diff --git a/packages/viewer/src/lib/backdrop.ts b/packages/viewer/src/lib/backdrop.ts index 87e8c15cf..bb172193e 100644 --- a/packages/viewer/src/lib/backdrop.ts +++ b/packages/viewer/src/lib/backdrop.ts @@ -30,29 +30,29 @@ export function backdropGradient({ // a single pale stop reads as a white void with blue "hiding" up top. let base = (mix as any)(background, sky, smoothstep(-0.02, 0.14, dirY)) base = (mix as any)(base, skyDeep, smoothstep(0.1, 0.55, dirY)) - // Haze as an exponential glow peaking exactly at the horizon: C¹-smooth on + // Haze as an exponential glow hugging the horizon tightly: C¹-smooth on // both sides, so it brightens the junction without ever drawing an edge. - const hazeWeight = exp(abs(dirY).mul(-7)).mul(0.9) + const hazeWeight = exp(abs(dirY).mul(-11)).mul(0.8) return (mix as any)(base, haze, hazeWeight) } -// Warm white the haze lifts toward — reads as sun-scattered atmosphere at the -// horizon (the slight yellow inZOI-style skies have) rather than sterile fog. -const HAZE_WARM_WHITE = { r: 255, g: 244, b: 222 } +// Warm sun tint the haze pulls toward — aerial perspective is sky-coloured +// light plus a little sun scatter, not white fog. +const HAZE_SUN_TINT = [255, 244, 222] as const /** - * Atmospheric haze tint at the horizon: the theme background lifted toward a - * warm white. Light themes get a bright glow, dark themes a faint one (reads - * as scattered city light). + * Atmospheric haze at the horizon: the theme's *sky* colour pulled toward a + * warm sun tint — skyish and sunish at once, so the band reads as part of + * the sky rather than a white stripe. Dark themes keep it faint (a low glow + * over the night zenith). */ -export function horizonHazeColor(background: string, appearance: 'light' | 'dark'): string { - const amount = appearance === 'dark' ? 0.12 : 0.25 - const hex = background.replace('#', '') - const warm = [HAZE_WARM_WHITE.r, HAZE_WARM_WHITE.g, HAZE_WARM_WHITE.b] +export function horizonHazeColor(sky: string, appearance: 'light' | 'dark'): string { + const amount = appearance === 'dark' ? 0.25 : 0.5 + const hex = sky.replace('#', '') let out = '#' for (let i = 0; i < 3; i++) { const c = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16) - out += Math.round(c + (warm[i]! - c) * amount) + out += Math.round(c + (HAZE_SUN_TINT[i]! - c) * amount) .toString(16) .padStart(2, '0') }