Merge branch 'main' into feat/elevator-system

This commit is contained in:
Sudhir Yadav
2026-05-13 22:29:09 +05:30
committed by GitHub
10 changed files with 303 additions and 83 deletions
@@ -1,12 +1,12 @@
import {
useInteractive,
useRegistry,
useScene,
type AnimationEffect,
type AnyNodeId,
type Interactive,
type ItemNode,
type LightEffect,
useInteractive,
useRegistry,
useScene,
} from '@pascal-app/core'
import { useAnimations } from '@react-three/drei'
import { Clone } from '@react-three/drei/core/Clone'
@@ -24,7 +24,7 @@ import { useItemLightPool } from '../../../store/use-item-light-pool'
import { ErrorBoundary } from '../../error-boundary'
import { NodeRenderer } from '../node-renderer'
const getMaterialForOriginal = (original: Material): MeshStandardNodeMaterial => {
const getMaterialForOriginal = (original: Material): Material => {
if (original.name.toLowerCase() === 'glass') {
return glassMaterial
}
@@ -1,6 +1,6 @@
'use client'
import { type AnyNode, useScene } from '@pascal-app/core'
import { useScene, type AnyNode } from '@pascal-app/core'
import { BuildingRenderer } from './building/building-renderer'
import { CeilingRenderer } from './ceiling/ceiling-renderer'
import { ColumnRenderer } from './column/column-renderer'
@@ -10,14 +10,14 @@ import { FenceRenderer } from './fence/fence-renderer'
import { GuideRenderer } from './guide/guide-renderer'
import { ItemRenderer } from './item/item-renderer'
import { LevelRenderer } from './level/level-renderer'
import { RoofRenderer } from './roof/roof-renderer'
import { RoofSegmentRenderer } from './roof-segment/roof-segment-renderer'
import { RoofRenderer } from './roof/roof-renderer'
import { ScanRenderer } from './scan/scan-renderer'
import { SiteRenderer } from './site/site-renderer'
import { SlabRenderer } from './slab/slab-renderer'
import { SpawnRenderer } from './spawn/spawn-renderer'
import { StairRenderer } from './stair/stair-renderer'
import { StairSegmentRenderer } from './stair-segment/stair-segment-renderer'
import { StairRenderer } from './stair/stair-renderer'
import { WallRenderer } from './wall/wall-renderer'
import { WindowRenderer } from './window/window-renderer'
import { ZoneRenderer } from './zone/zone-renderer'
@@ -1,6 +1,6 @@
import { type SiteNode, type SlabNode, useRegistry, useScene } from '@pascal-app/core'
import { useRegistry, useScene, type SiteNode, type SlabNode } from '@pascal-app/core'
import { useMemo, useRef } from 'react'
import { BufferGeometry, Float32BufferAttribute, type Group, Path, Shape } from 'three'
import { BufferGeometry, Float32BufferAttribute, Path, Shape, type Group } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { unionPolygons } from '../../../lib/polygon-union'
import useViewer from '../../../store/use-viewer'
@@ -126,15 +126,23 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
{/* Ground fill: site polygon with slab holes, occludes below-grade geometry */}
{groundShape && (
<mesh position={[0, -0.05, 0]} receiveShadow rotation={[-Math.PI / 2, 0, 0]}>
<mesh position={[0, -0.05, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<shapeGeometry args={[groundShape]} />
<meshStandardMaterial
{/* PERF TEST: basic material — no PBR / shadows / lighting calc.
Ground color = canvas background, so lighting is invisible work. */}
<meshBasicMaterial
color={bgColor}
polygonOffset={true}
polygonOffsetFactor={1}
polygonOffsetUnits={1}
/>
{/* <meshStandardMaterial
color={bgColor}
depthWrite={true}
polygonOffset={true}
polygonOffsetFactor={1}
polygonOffsetUnits={1}
/>
/> */}
</mesh>
)}
@@ -22,10 +22,8 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => {
return (
<mesh
castShadow
material={material}
position={node.position}
receiveShadow
ref={ref}
rotation={node.rotation}
visible={node.visible}
@@ -4,6 +4,7 @@ import { ElevatorOpeningSystem, ElevatorRuntimeSystem } from '@pascal-app/core'
import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three/webgpu'
import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf'
import useViewer from '../../store/use-viewer'
import { CeilingSystem } from '../../systems/ceiling/ceiling-system'
import { DoorAnimationSystem } from '../../systems/door/door-animation-system'
@@ -11,8 +12,8 @@ import { DoorSystem } from '../../systems/door/door-system'
import { ElevatorInteractionSystem } from '../../systems/elevator/elevator-interaction-system'
import { FenceSystem } from '../../systems/fence/fence-system'
import { GuideSystem } from '../../systems/guide/guide-system'
import { ItemSystem } from '../../systems/item/item-system'
import { ItemLightSystem } from '../../systems/item-light/item-light-system'
import { ItemSystem } from '../../systems/item/item-system'
import { LevelSystem } from '../../systems/level/level-system'
import { RoofSystem } from '../../systems/roof/roof-system'
import { ScanSystem } from '../../systems/scan/scan-system'
@@ -160,11 +161,16 @@ const Viewer: React.FC<ViewerProps> = ({
useBvh = true,
}) => {
const theme = useViewer((state) => state.theme)
// Coarse-pointer devices (phones/tablets) get a tighter DPR ceiling to keep
// fragment-shader cost down — saves another ~30% over 1.5x on high-DPI mobile.
// Desktops (fine pointer) keep the original 1.5 cap.
const maxDpr =
typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches ? 1.25 : 1.5
return (
<Canvas
camera={{ position: [50, 50, 50], fov: 50 }}
className={`transition-colors duration-700 ${theme === 'dark' ? 'bg-[#1f2433]' : 'bg-[#fafafa]'}`}
dpr={[1, 1.5]}
dpr={[1, maxDpr]}
frameloop="never"
gl={
((props: { canvas?: HTMLCanvasElement }) => {
@@ -242,7 +248,7 @@ const Viewer: React.FC<ViewerProps> = ({
<ItemLightSystem />
{selectionManager === 'default' && <SelectionManager />}
{perf && <PerfMonitor />}
{(perf || PERF_OVERLAY_ENABLED) && <PerfMonitor />}
{children}
</ErrorBoundary>
</Canvas>
@@ -251,7 +257,16 @@ const Viewer: React.FC<ViewerProps> = ({
const DebugRenderer = () => {
useFrame(({ gl, scene, camera }) => {
const submittedAt = PERF_OVERLAY_ENABLED ? performance.now() : 0
gl.render(scene, camera)
if (PERF_OVERLAY_ENABLED) {
const queue = (gl as any).backend?.device?.queue as
| { onSubmittedWorkDone?: () => Promise<void> }
| undefined
queue?.onSubmittedWorkDone?.().then(() => {
pushGpuSample(performance.now() - submittedAt)
})
}
})
return null
}
@@ -4,6 +4,17 @@ import type { AmbientLight, DirectionalLight, OrthographicCamera } from 'three/w
import * as THREE from 'three/webgpu'
import useViewer from '../../store/use-viewer'
// 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.
const SHADOWS_DISABLED =
typeof window !== 'undefined' &&
new Set(
(new URLSearchParams(window.location.search).get('disable') ?? '')
.split(',')
.map((s) => s.trim()),
).has('shadows')
export function Lights() {
const theme = useViewer((state) => state.theme)
const isDark = theme === 'dark'
@@ -109,24 +120,26 @@ export function Lights() {
return (
<>
<directionalLight
castShadow
castShadow={!SHADOWS_DISABLED}
position={[10, 10, 10]}
ref={light1Ref}
shadow-bias={-0.002}
shadow-mapSize={[1024, 1024]}
shadow-normalBias={0.3}
shadow-radius={3}
shadow-radius={2}
>
<orthographicCamera
attach="shadow-camera"
bottom={-shadowCameraSize}
far={100}
left={-shadowCameraSize}
near={1}
ref={shadowCamera}
right={shadowCameraSize}
top={shadowCameraSize}
/>
{SHADOWS_DISABLED ? null : (
<orthographicCamera
attach="shadow-camera"
bottom={-shadowCameraSize}
far={100}
left={-shadowCameraSize}
near={1}
ref={shadowCamera}
right={shadowCameraSize}
top={shadowCameraSize}
/>
)}
</directionalLight>
<directionalLight position={[-10, 10, -10]} ref={light2Ref} />
@@ -1,17 +1,50 @@
import { useScene } from '@pascal-app/core'
import { Html } from '@react-three/drei'
import { useFrame } from '@react-three/fiber'
import { useRef, useState } from 'react'
import { useFrame, useThree } from '@react-three/fiber'
import { useEffect, useRef, useState } from 'react'
import { drainGpuSamples } from '../../lib/gpu-perf'
const SAMPLE_INTERVAL = 0.5 // seconds between display updates
export const PerfMonitor = () => {
const [stats, setStats] = useState({ fps: 0, frameMs: 0, drawCalls: 0, triangles: 0, dirty: 0 })
const [stats, setStats] = useState({
fps: 0,
frameMs: 0,
gpuMs: 0,
gpuMaxMs: 0,
drawCalls: 0,
triangles: 0,
dirty: 0,
meshes: 0,
lines: 0,
sprites: 0,
lights: 0,
})
const frameCount = useRef(0)
const elapsed = useRef(0)
const lastMs = useRef(0)
// Carry the previous tick's reading forward when no fresh samples arrive,
// so the display doesn't flicker to "—" on slow resolve windows.
const lastGpuMs = useRef(0)
const lastGpuMaxMs = useRef(0)
useFrame(({ gl, clock }) => {
// Take ownership of info reset. The custom RenderPipeline.render() path
// we use in post-processing doesn't trigger three.js's automatic per-frame
// info reset, so calls/triangles accumulate across frames and the display
// shows lifetime totals. Disabling autoReset and explicitly resetting at
// each window gives true per-frame averages.
const gl = useThree((s) => s.gl)
useEffect(() => {
if (!gl?.info) return
const previousAutoReset = gl.info.autoReset
gl.info.autoReset = false
gl.info.reset()
return () => {
gl.info.autoReset = previousAutoReset
}
}, [gl])
useFrame(({ gl, scene, clock }) => {
frameCount.current++
const now = clock.elapsedTime
const dt = now - elapsed.current
@@ -20,11 +53,57 @@ export const PerfMonitor = () => {
const fps = Math.round(frameCount.current / dt)
const frameMs = lastMs.current
const info = gl.info
const drawCalls = info.render?.calls ?? 0
const triangles = info.render?.triangles ?? 0
// calls/triangles have been accumulating since the last reset (start of
// window). Divide by frameCount to get a per-frame average.
const totalCalls = info.render?.calls ?? 0
const totalTriangles = info.render?.triangles ?? 0
const drawCalls = Math.round(totalCalls / Math.max(1, frameCount.current))
const triangles = totalTriangles / Math.max(1, frameCount.current)
info.reset()
const dirty = useScene.getState().dirtyNodes.size
setStats({ fps, frameMs, drawCalls, triangles, dirty })
// Count visible drawables by type so we can match scene contents
// against the renderer's draw count and find hidden contributors.
let meshes = 0
let lines = 0
let sprites = 0
let lights = 0
scene.traverse((obj: any) => {
if (!obj.visible) return
if (obj.isMesh) meshes++
else if (obj.isLine || obj.isLineSegments || obj.isLineLoop) lines++
else if (obj.isSprite) sprites++
else if (obj.isLight) lights++
})
// GPU samples are pushed by post-processing.tsx after each pipeline
// render via device.queue.onSubmittedWorkDone(). We drain whatever
// has accumulated since the last tick.
const samples = drainGpuSamples()
if (samples.length > 0) {
let sum = 0
let max = 0
for (const s of samples) {
sum += s
if (s > max) max = s
}
lastGpuMs.current = sum / samples.length
lastGpuMaxMs.current = max
}
setStats({
fps,
frameMs,
gpuMs: lastGpuMs.current,
gpuMaxMs: lastGpuMaxMs.current,
drawCalls,
triangles,
dirty,
meshes,
lines,
sprites,
lights,
})
frameCount.current = 0
elapsed.current = now
}
@@ -50,10 +129,15 @@ export const PerfMonitor = () => {
whiteSpace: 'pre',
}}
>
{`FPS ${stats.fps}
DRAW ${stats.drawCalls}
TRI ${(stats.triangles / 1000).toFixed(1)}k
DIRTY ${stats.dirty}`}
{`FPS ${stats.fps}
GPU ${stats.gpuMs > 0 ? `${stats.gpuMs.toFixed(1)}ms (max ${stats.gpuMaxMs.toFixed(1)})` : '—'}
DRAW ${stats.drawCalls}
TRI ${(stats.triangles / 1000).toFixed(1)}k
DIRTY ${stats.dirty}
MESH ${stats.meshes}
LINE ${stats.lines}
SPRITE ${stats.sprites}
LIGHT ${stats.lights}`}
</div>
</Html>
)
@@ -21,6 +21,7 @@ import {
vec4,
} from 'three/tsl'
import { RenderPipeline, type WebGPURenderer } from 'three/webgpu'
import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf'
import { SCENE_LAYER, ZONE_LAYER } from '../../lib/layers'
import { mergedOutline } from '../../lib/merged-outline-node'
import useViewer from '../../store/use-viewer'
@@ -41,6 +42,43 @@ export const SSGI_PARAMS = {
useTemporalFiltering: false,
}
// 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
// across combos isolate which pass is the actual culprit. Picked up once at
// pipeline build; reload after changing the URL.
// - ao: skip SSGI entirely (and denoise, since denoise has nothing to denoise)
// - denoise: keep SSGI but feed its raw noisy AO straight to the composite
// - outline: skip the merged-outline node and its 14 internal RTs
// - postFx: bypass the whole RenderPipeline and use renderer.render(scene, camera)
// directly — isolates raw scene-render cost from any post-FX overhead
function readPerfDisableFlags() {
if (typeof window === 'undefined') {
return { ao: false, denoise: false, outline: false, postFx: false }
}
const raw = new URLSearchParams(window.location.search).get('disable') ?? ''
const set = new Set(
raw
.split(',')
.map((s) => s.trim())
.filter(Boolean),
)
return {
ao: set.has('ao'),
denoise: set.has('denoise'),
outline: set.has('outline'),
postFx: set.has('postFx'),
}
}
const PERF_POST_FX_DISABLED =
typeof window !== 'undefined' &&
new Set(
(new URLSearchParams(window.location.search).get('disable') ?? '')
.split(',')
.map((s) => s.trim()),
).has('postFx')
const MAX_PIPELINE_RETRIES = 3
const RETRY_DELAY_MS = 500
@@ -178,9 +216,17 @@ const PostProcessingPasses = ({
return
}
const perfDisable = readPerfDisableFlags()
const ssgiEnabled = SSGI_PARAMS.enabled && !perfDisable.ao
const denoiseEnabled = ssgiEnabled && !perfDisable.denoise
const outlineEnabled = !perfDisable.outline
console.log('[viewer/post-processing] Building pipeline', {
version: pipelineVersion,
ssgi: SSGI_PARAMS.enabled,
ssgi: ssgiEnabled,
denoise: denoiseEnabled,
outline: outlineEnabled,
perfDisable,
hoverHighlightMode,
projectId,
rendererCtor: (renderer as any).constructor?.name,
@@ -227,7 +273,7 @@ const PostProcessingPasses = ({
let sceneColor = scenePassColor as unknown as ReturnType<typeof vec4>
if (SSGI_PARAMS.enabled) {
if (ssgiEnabled) {
// MRT only needed for SSGI (diffuse for GI, normal for SSGI sampling)
scenePass.setMRT(
mrt({
@@ -265,15 +311,21 @@ const PostProcessingPasses = ({
const giTexture = (giPass as any).getTextureNode()
// DenoiseNode only denoises RGB — alpha is passed through unchanged.
// SSGI packs AO into alpha, so we remap it into RGB before denoising.
const aoAsRgb = vec4(giTexture.a, giTexture.a, giTexture.a, float(1))
const denoisePass = denoise(aoAsRgb, scenePassDepth, sceneNormal, camera)
denoisePass.index.value = 0
denoisePass.radius.value = 4
const gi = giPass.rgb
const ao = (denoisePass as any).r
let ao: any
if (denoiseEnabled) {
// DenoiseNode only denoises RGB — alpha is passed through unchanged.
// SSGI packs AO into alpha, so we remap it into RGB before denoising.
const aoAsRgb = vec4(giTexture.a, giTexture.a, giTexture.a, float(1))
const denoisePass = denoise(aoAsRgb, scenePassDepth, sceneNormal, camera)
denoisePass.index.value = 0
denoisePass.radius.value = 4
ao = (denoisePass as any).r
} else {
// Diagnostic path: feed raw noisy SSGI AO straight through. Will
// look grainy — that's the point, it isolates denoise cost.
ao = giTexture.a
}
// Composite: scene * AO + diffuse * GI
sceneColor = vec4(
@@ -284,36 +336,39 @@ const PostProcessingPasses = ({
// Single merged outline node: one shared depth pass for both selected + hovered groups.
const outliner = useViewer.getState().outliner
const outlineNode = mergedOutline(scene, camera, {
primaryObjects: outliner.selectedObjects,
secondaryObjects: outliner.hoveredObjects,
primaryEdgeThickness: uniform(1),
secondaryEdgeThickness: uniform(1.5),
})
let compositeWithOutlines = sceneColor
if (outlineEnabled) {
const outlineNode = mergedOutline(scene, camera, {
primaryObjects: outliner.selectedObjects,
secondaryObjects: outliner.hoveredObjects,
primaryEdgeThickness: uniform(1),
secondaryEdgeThickness: uniform(1.5),
})
// Selected: white visible, yellow hidden
const selectedVisibleColor = uniform(new Color(0xff_ff_ff))
const selectedHiddenColor = uniform(new Color(0xf3_ff_47))
const selectedStrength = uniform(3)
const selectedOutline = outlineNode.primaryVisibleEdge
.mul(selectedVisibleColor)
.add(outlineNode.primaryHiddenEdge.mul(selectedHiddenColor))
.mul(selectedStrength)
// Selected: white visible, yellow hidden
const selectedVisibleColor = uniform(new Color(0xff_ff_ff))
const selectedHiddenColor = uniform(new Color(0xf3_ff_47))
const selectedStrength = uniform(3)
const selectedOutline = outlineNode.primaryVisibleEdge
.mul(selectedVisibleColor)
.add(outlineNode.primaryHiddenEdge.mul(selectedHiddenColor))
.mul(selectedStrength)
// Hovered: blue visible, yellow hidden, pulsing
const pulsePeriod = uniform(3)
const oscillating = oscSine(time.div(pulsePeriod).mul(2)).mul(0.5).add(0.5)
const osc = mix(oscillating, float(1), hoverPulseMix)
const hoverOutline = outlineNode.secondaryVisibleEdge
.mul(hoverVisibleColor)
.add(outlineNode.secondaryHiddenEdge.mul(hoverHiddenColor))
.mul(hoverStrength)
.mul(osc)
// Hovered: blue visible, yellow hidden, pulsing
const pulsePeriod = uniform(3)
const oscillating = oscSine(time.div(pulsePeriod).mul(2)).mul(0.5).add(0.5)
const osc = mix(oscillating, float(1), hoverPulseMix)
const hoverOutline = outlineNode.secondaryVisibleEdge
.mul(hoverVisibleColor)
.add(outlineNode.secondaryHiddenEdge.mul(hoverHiddenColor))
.mul(hoverStrength)
.mul(osc)
const compositeWithOutlines = vec4(
add(sceneColor.rgb, selectedOutline.add(hoverOutline)),
sceneColor.a,
)
compositeWithOutlines = vec4(
add(sceneColor.rgb, selectedOutline.add(hoverOutline)),
sceneColor.a,
)
}
const finalOutput = vec4(
mix(bgUniform.current, compositeWithOutlines.rgb, contentAlpha),
@@ -371,12 +426,21 @@ const PostProcessingPasses = ({
sanitizeOutlineObjects(outliner.selectedObjects)
sanitizeOutlineObjects(outliner.hoveredObjects)
if (hasPipelineErrorRef.current || !renderPipelineRef.current) {
if (PERF_POST_FX_DISABLED || hasPipelineErrorRef.current || !renderPipelineRef.current) {
try {
if ((renderer as any).setClearAlpha) {
;(renderer as any).setClearAlpha(1)
}
const submittedAt = PERF_OVERLAY_ENABLED ? performance.now() : 0
;(renderer as any).render(scene, camera)
if (PERF_OVERLAY_ENABLED) {
const queue = (renderer as any).backend?.device?.queue as
| { onSubmittedWorkDone?: () => Promise<void> }
| undefined
queue?.onSubmittedWorkDone?.().then(() => {
pushGpuSample(performance.now() - submittedAt)
})
}
} catch (fallbackError) {
console.error('[viewer/post-processing] Fallback render failed.', fallbackError)
}
@@ -387,7 +451,21 @@ const PostProcessingPasses = ({
// Clear alpha=0 so background pixels in the output MRT attachment (index 0) get a=0,
// making scenePassColor.a a reliable geometry mask (geometry pixels write a=1 via output node).
;(renderer as any).setClearAlpha(0)
const submittedAt = PERF_OVERLAY_ENABLED ? performance.now() : 0
renderPipelineRef.current.render()
if (PERF_OVERLAY_ENABLED) {
// device.queue.onSubmittedWorkDone() resolves once the GPU has
// finished the work we just submitted — the delta from our submit
// timestamp is a clean per-frame GPU duration. Doesn't block CPU
// (no await) and works for the custom RenderPipeline path that
// bypasses three.js's timestamp-query infrastructure.
const queue = (renderer as any).backend?.device?.queue as
| { onSubmittedWorkDone?: () => Promise<void> }
| undefined
queue?.onSubmittedWorkDone?.().then(() => {
pushGpuSample(performance.now() - submittedAt)
})
}
} catch (error) {
hasPipelineErrorRef.current = true
console.error('[viewer/post-processing] Render pass failed.', {
+26
View File
@@ -0,0 +1,26 @@
// GPU work-time measurement, gated by `?perf` in the URL.
//
// We can't use WebGPU timestamp queries here because the editor renders via
// a custom `RenderPipeline.render()` path that bypasses three.js's built-in
// timestamp infrastructure. Instead we use `device.queue.onSubmittedWorkDone()`,
// which resolves when the GPU finishes all submitted work — measuring the
// CPU→GPU-done delta gives a clean approximation of per-frame GPU duration
// regardless of which render path produced it.
export const PERF_OVERLAY_ENABLED =
typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('perf')
const MAX_SAMPLES = 256
const samples: number[] = []
export function pushGpuSample(ms: number): void {
samples.push(ms)
if (samples.length > MAX_SAMPLES) samples.shift()
}
export function drainGpuSamples(): number[] {
if (samples.length === 0) return []
const out = samples.slice()
samples.length = 0
return out
}
+3 -5
View File
@@ -1,13 +1,13 @@
import {
getMaterialPresetByRef,
resolveMaterial,
type MaterialMapProperties,
type MaterialPresetPayload,
type MaterialProperties,
type MaterialSchema,
resolveMaterial,
} from '@pascal-app/core'
import * as THREE from 'three'
import { MeshStandardNodeMaterial } from 'three/webgpu'
import { MeshLambertNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu'
export const baseMaterial = new MeshStandardNodeMaterial({
color: '#f2f0ed',
@@ -15,10 +15,8 @@ export const baseMaterial = new MeshStandardNodeMaterial({
metalness: 0.0,
})
export const glassMaterial = new MeshStandardNodeMaterial({
export const glassMaterial = new MeshLambertNodeMaterial({
color: '#e0f2fe',
roughness: 0.05,
metalness: 0.0,
transparent: true,
opacity: 0.35,
side: THREE.DoubleSide,