diff --git a/app/components/Form.tsx b/app/components/Form.tsx index 10d871a..ba29323 100644 --- a/app/components/Form.tsx +++ b/app/components/Form.tsx @@ -14,9 +14,10 @@ import { SceneViewContext, SceneViewContextType, } from "../providers/scene-view-provider"; -import { Mesh, MeshStandardMaterial } from "three"; +import { Mesh } from "three"; import { CustomEvent } from "../three/SceneView"; import { RangeSlider } from "./RangeSlider"; +import { MeshStandardNodeMaterial } from "three/webgpu"; function Toggle({ title, @@ -263,10 +264,10 @@ export function Form() { const key = `toggle-visibility-${child.name}`; let color = "transparent"; if ( - (child as Mesh).material instanceof MeshStandardMaterial + (child as Mesh).material instanceof MeshStandardNodeMaterial ) { color = `#${( - (child as Mesh).material as MeshStandardMaterial + (child as Mesh).material as MeshStandardNodeMaterial ).color.getHexString()}`; } const visible = (child as Mesh).visible; diff --git a/app/three/SceneView.ts b/app/three/SceneView.ts index 67457b1..131e16b 100644 --- a/app/three/SceneView.ts +++ b/app/three/SceneView.ts @@ -1,10 +1,8 @@ import { Group, - Material, Mesh, MeshBasicMaterial, MeshPhongMaterial, - MeshStandardMaterial, PerspectiveCamera, Plane, Raycaster, @@ -12,7 +10,6 @@ import { SphereGeometry, Vector2, Vector3, - WebGLRenderer, } from "three"; import { buildMeshes } from "./utils/build-meshes"; import { Extent, animate, buildScene } from "./utils/build-scene"; @@ -39,6 +36,11 @@ import { } from "geo-three"; import { Data, createSVG } from "./utils/create-borehole-svg"; import { TileData, updateTiles } from "./ShaderMaterial"; +import { + ClippingGroup, + MeshStandardNodeMaterial, + WebGPURenderer, +} from "three/webgpu"; export type CustomEvent = CustomEventInit<{ element: SVGSVGElement | null; @@ -46,7 +48,7 @@ export type CustomEvent = CustomEventInit<{ export class SceneView extends EventTarget { private _scene: Scene; - private _model: Group; + private _model: ClippingGroup; private _camera: PerspectiveCamera; private _container: HTMLElement; private _raycaster: Raycaster; @@ -58,17 +60,17 @@ export class SceneView extends EventTarget { private _callback: EventListenerOrEventListenerObject | null = null; private _orbitControls: OrbitControls; private _dragControls: DragControls | null = null; - private _renderer: WebGLRenderer; + private _renderer: WebGPURenderer; private static _DISPLACEMENT = 2000; constructor( scene: Scene, - model: Group, + model: ClippingGroup, camera: PerspectiveCamera, container: HTMLElement, extent: Extent, orbitControls: OrbitControls, - renderer: WebGLRenderer + renderer: WebGPURenderer ) { super(); this._scene = scene; @@ -151,7 +153,7 @@ export class SceneView extends EventTarget { // Set wireframe for model const model = this._model; model.children.forEach((child) => { - const material = (child as Mesh).material as MeshStandardMaterial; + const material = (child as Mesh).material as MeshStandardNodeMaterial; material.wireframe = !material.wireframe; }); @@ -162,7 +164,7 @@ export class SceneView extends EventTarget { if (capMeshGroup) { capMeshGroup.children.forEach((mesh) => { - const material = (mesh as Mesh).material as MeshStandardMaterial; + const material = (mesh as Mesh).material as MeshStandardNodeMaterial; if (material) { material.wireframe = !material.wireframe; } @@ -213,7 +215,7 @@ export class SceneView extends EventTarget { const depthEnd = intersects[i + 1].point.z; const name = intersects[i].object.name; const color = `#${( - (intersects[i].object as Mesh).material as MeshStandardMaterial + (intersects[i].object as Mesh).material as MeshStandardNodeMaterial ).color.getHexString()}`; // Avoid duplicate entries, just update the depth information @@ -339,10 +341,9 @@ export class SceneView extends EventTarget { // Remove existing cap meshes for (const o in Orientation) { const capMeshGroupName = `cap-mesh-group-${o}`; - let capMeshGroup = this._scene.getObjectByName(capMeshGroupName); - while (capMeshGroup) { - this._scene.remove(capMeshGroup); - capMeshGroup = this._scene.getObjectByName(capMeshGroupName); + const capMeshGroup = this._scene.getObjectByName(capMeshGroupName); + if (capMeshGroup) { + capMeshGroup.clear(); } } @@ -352,10 +353,8 @@ export class SceneView extends EventTarget { this._dragControls = null; } - // Remove clipping planes - for (const mesh of this._model.children) { - ((mesh as Mesh).material as Material).clippingPlanes = null; - } + // Disable clipping group + this.model.enabled = false; } // Reset clipping box @@ -373,10 +372,9 @@ export class SceneView extends EventTarget { this._dragControls = dragControls; - // Add clipping planes to the meshes - for (const mesh of this._model.children) { - ((mesh as Mesh).material as Material).clippingPlanes = planes; - } + // Add planes to ClippingGroup + this.model.clippingPlanes = planes; + this.model.enabled = true; } // Explode meshes @@ -454,7 +452,8 @@ async function init(container: HTMLElement, modelId = MODEL_ID) { // Build the 3D model const meshes = await buildMeshes(mappedFeatures); - const model = new Group(); + const model = new ClippingGroup(); + model.enabled = false; model.add(...meshes); model.name = "geologic-model"; scene.add(model); @@ -510,7 +509,7 @@ async function init(container: HTMLElement, modelId = MODEL_ID) { function rendererCallback( camera: PerspectiveCamera, - renderer: WebGLRenderer, + renderer: WebGPURenderer, scene: Scene, map: MapView, extent: Extent, @@ -518,6 +517,7 @@ function rendererCallback( ) { return () => { if (topography.visible) { + //@ts-expect-error WebGPURenderer is not supported by Geo-Three map.lod.updateLOD(map, camera, renderer, scene); const tiles: TileData[] = []; traverse(map.root, extent, tiles); diff --git a/app/three/ShaderMaterial.ts b/app/three/ShaderMaterial.ts index 11f61ca..b94728f 100644 --- a/app/three/ShaderMaterial.ts +++ b/app/three/ShaderMaterial.ts @@ -1,13 +1,24 @@ import { - Color, DataArrayTexture, LinearFilter, RGBAFormat, - ShaderChunk, - ShaderMaterial, + SRGBColorSpace, Texture, Vector4, } from "three"; +import { + Break, + Fn, + If, + Loop, + oneMinus, + positionWorld, + texture, + uniform, + uniformArray, + vec4, +} from "three/tsl"; +import { MeshStandardNodeMaterial } from "three/webgpu"; export interface TileData { xmin: number; @@ -26,9 +37,9 @@ const height = 256; const size = width * height; const canvas = new OffscreenCanvas(width, height); -const ctx = canvas.getContext("2d"); +const ctx = canvas.getContext("2d", { willReadFrequently: true }); -const tileBounds = Array(maxTiles).fill(new Vector4(0, 0, 0, 0)); +const tileBounds: Vector4[] = Array(maxTiles).fill(new Vector4(0, 0, 0, 0)); const data = new Uint8Array(4 * size * maxTiles); const tileCache: { @@ -42,92 +53,65 @@ dataArrayTexture.format = RGBAFormat; dataArrayTexture.generateMipmaps = false; dataArrayTexture.magFilter = LinearFilter; dataArrayTexture.minFilter = LinearFilter; +dataArrayTexture.colorSpace = SRGBColorSpace; dataArrayTexture.needsUpdate = true; // Create shader material -export const shaderMaterial = new ShaderMaterial({ - clipping: true, - uniforms: { - tileBounds: { value: tileBounds }, - tileCount: { value: maxTiles }, - tiles: { value: dataArrayTexture }, - color: { value: new Color(1, 1, 1) }, - }, - vertexShader: - ShaderChunk.common + - "\n" + - ShaderChunk.logdepthbuf_pars_vertex + - ` - varying vec3 vWorldPosition; - varying float fragDepth; +export const topoNodeMaterial = new MeshStandardNodeMaterial({ + alphaToCoverage: true, +}); +const tileBoundsUniform = uniformArray(tileBounds); +const dataArrayTextureUniform = uniform(dataArrayTexture); - #include +const fragmentShader = /*#__PURE__*/ Fn(() => { + const color = vec4(191.0 / 255.0, 209.0 / 255.0, 229.0 / 255.0, 1.0).toVar(); + Loop({ start: 0, end: maxTiles, condition: "<" }, ({ i }) => { + const bounds = tileBoundsUniform.element(i); - void main() { - #include - vWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz; - gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); - fragDepth = (gl_Position.z / gl_Position.w + 1.0) * 0.5; + If( + positionWorld.x + .greaterThanEqual(bounds.x) + .and(positionWorld.x.lessThanEqual(bounds.y)) + .and(positionWorld.y.greaterThanEqual(bounds.z)) + .and(positionWorld.y.lessThanEqual(bounds.w)), + () => { + let uv = positionWorld.xy + .sub(bounds.xz) + .div(bounds.yw.sub(bounds.xz)) + .toVar(); + uv.y.assign(oneMinus(uv.y)); - #include - #include - - ` + - ShaderChunk.logdepthbuf_vertex + - ` - } -`, - fragmentShader: - ShaderChunk.logdepthbuf_pars_fragment + - ` - uniform vec4 tileBounds[${maxTiles}]; - uniform int tileCount; - uniform sampler2DArray tiles; - varying vec3 vWorldPosition; - varying float fragDepth; + const tile = texture(dataArrayTextureUniform.value, uv); + color.assign(tile.depth(i)); + Break(); + } + ); + }); - #include - - void main() { - #include - - vec4 color = vec4(191.0/255.0, 209.0/255.0, 229.0/255.0, 1.0); // Default color - - for (int i = 0; i < ${maxTiles}; i++) { - if (i >= tileCount) break; // Only process available tiles - - vec4 bounds = tileBounds[i]; - - if (vWorldPosition.x >= bounds.x && vWorldPosition.x <= bounds.y && - vWorldPosition.y >= bounds.z && vWorldPosition.y <= bounds.w) { - - vec2 uv = (vWorldPosition.xy - bounds.xz) / (bounds.yw - bounds.xz); - uv = vec2(uv.x, 1.0 - uv.y); - color = texture2D(tiles, vec3(uv, i)); - - break; // Stop checking once we find the correct tile - } - } - - gl_FragColor = color; - gl_FragDepth = fragDepth; - ` + - ShaderChunk.logdepthbuf_fragment + - ` - } -`, + return color; }); +topoNodeMaterial.colorNode = fragmentShader(); + +let oldKeys: string[] = []; export function updateTiles(newTiles: TileData[]) { if (newTiles.length > maxTiles) { newTiles = newTiles.slice(0, maxTiles); } - for (let i = 0; i < newTiles.length; i++) { - updateDataArrayTexture(newTiles[i], i); - } + const newKeys = newTiles.map((t) => getTileDataKey(t)); + const update = + oldKeys.some((k, i) => k !== newKeys[i]) || oldKeys.length === 0; - dataArrayTexture.needsUpdate = true; + // Only update if tiles changed + if (update) { + for (let i = 0; i < newTiles.length; i++) { + updateDataArrayTexture(newTiles[i], i); + } + + dataArrayTexture.needsUpdate = true; + oldKeys = newKeys; + } } // Update buffer diff --git a/app/three/utils/build-clipping-planes.ts b/app/three/utils/build-clipping-planes.ts index 4c93d9f..c290404 100644 --- a/app/three/utils/build-clipping-planes.ts +++ b/app/three/utils/build-clipping-planes.ts @@ -8,7 +8,6 @@ import { LineBasicMaterial, LineSegments, Mesh, - MeshBasicMaterial, MeshStandardMaterial, Object3DEventMap, PerspectiveCamera, @@ -17,11 +16,17 @@ import { Scene, Vector2, Vector3, - WebGLRenderer, } from "three"; import { DragControls, OrbitControls } from "three/examples/jsm/Addons.js"; import { Extent } from "./build-scene"; import earcut from "earcut"; +import { + ClippingGroup, + MeshBasicNodeMaterial, + MeshStandardNodeMaterial, + WebGPURenderer, +} from "three/webgpu"; +import { Fn, uniform, vec4 } from "three/tsl"; export enum Orientation { X = "X", @@ -32,7 +37,7 @@ export enum Orientation { NZ = "NZ", } -type PlaneMesh = Mesh; +type PlaneMesh = Mesh; type EdgeMesh = LineSegments< EdgesGeometry, LineBasicMaterial, @@ -49,7 +54,7 @@ let currentExtent: Extent; const BUFFER = 500; export function buildClippingplanes( - renderer: WebGLRenderer, + renderer: WebGPURenderer, camera: PerspectiveCamera, orbitControls: OrbitControls, extent: Extent, @@ -148,7 +153,7 @@ export function buildClippingplanes( const planeGeometry = new PlaneGeometry(width, height); const planeMesh = new Mesh( planeGeometry, - new MeshBasicMaterial({ + new MeshBasicNodeMaterial({ visible: true, color: 0xa92a4e, transparent: true, @@ -190,6 +195,19 @@ export function buildClippingplanes( edgeMeshMap[p.orientation] = edges; } + for (const o in Orientation) { + const capMeshGroupName = `cap-mesh-group-${o}`; + let capMeshGroup = scene.getObjectByName(capMeshGroupName) as ClippingGroup; + if (capMeshGroup) { + capMeshGroup.clear(); + } else { + capMeshGroup = new ClippingGroup(); + capMeshGroup.name = capMeshGroupName; + capMeshGroup.clippingPlanes = planes; + scene.add(capMeshGroup); + } + } + // Add meshes to the scene const planeMeshGroup = new Group(); planeMeshGroup.name = "clipping-planes"; @@ -388,29 +406,21 @@ export function buildClippingplanes( } // Remove existing cap meshes - const capMeshGroupName = `cap-mesh-group-${object.name}`; - let capMeshGroup = scene.getObjectByName(capMeshGroupName); - while (capMeshGroup) { - scene.remove(capMeshGroup); - capMeshGroup = scene.getObjectByName(capMeshGroupName); - } + const capMeshGroupName = `cap-mesh-group-${orientation}`; + const capMeshGroup = scene.getObjectByName( + capMeshGroupName + ) as ClippingGroup; + if (capMeshGroup) { + capMeshGroup.clear(); - // Generate new cap meshes - const capMeshes = generateCapMeshes( - meshes, - plane.clone(), - planes, - orientation, - scene - ); - - // Add new cap meshes - if (capMeshes.length > 0) { - const newCapMeshGroup = new Group(); - - newCapMeshGroup.add(...capMeshes); - newCapMeshGroup.name = capMeshGroupName; - scene.add(newCapMeshGroup); + // Generate new cap meshes + generateCapMeshes( + meshes, + plane.clone(), + orientation, + scene, + capMeshGroup + ); } }); @@ -576,12 +586,10 @@ function resizeClippingPlane( function generateCapMeshes( meshes: Mesh[], plane: Plane, - planes: Plane[], orientation: Orientation, - scene: Scene + scene: Scene, + capMeshGroup: ClippingGroup ) { - const capMeshes: Mesh[] = []; - // Rescale to local coordinates if (orientation === Orientation.Z || orientation === Orientation.NZ) plane.constant /= scene.scale.z; @@ -640,9 +648,6 @@ function generateCapMeshes( // Intersection surface can be a multipolygon consisting of disconnected polygons const polygons: Vector3[][] = buildPolygons(edges); - // Clip cap surfaces with clipping planes - const clippingPlanes = planes.filter((p) => !p.normal.equals(plane.normal)); - const offset = orientation === Orientation.NX || orientation === Orientation.NY || @@ -651,24 +656,30 @@ function generateCapMeshes( : -1; const color = - mesh.material instanceof MeshStandardMaterial + mesh.material instanceof MeshStandardNodeMaterial ? mesh.material.color : new Color(1, 1, 1); - const material = new MeshStandardMaterial({ + const material = new MeshStandardNodeMaterial({ color, side: DoubleSide, - metalness: 0.0, - roughness: 1.0, + metalness: 0.1, + roughness: 0.5, flatShading: true, polygonOffset: true, polygonOffsetFactor: offset, polygonOffsetUnits: offset, - clippingPlanes, wireframe: scene.userData.wireframe, + alphaToCoverage: true, }); - const localMeshes = polygons.map((polygon) => { + const tColor = uniform(new Color(color)); + const fragmentShader = Fn(() => { + return vec4(tColor.r, tColor.g, tColor.b, 1.0); + }); + material.fragmentNode = fragmentShader(); + + polygons.forEach((polygon) => { const geometry = triangulatePolygon(polygon, plane); const capMesh = new Mesh(geometry, material); @@ -687,13 +698,11 @@ function generateCapMeshes( } positionAttr.needsUpdate = true; - return capMesh; + if (capMesh) { + capMeshGroup.add(capMesh); + } }); - - capMeshes.push(...localMeshes); } - - return capMeshes; } // Build polygons by grouping connected intersection edges diff --git a/app/three/utils/build-meshes.ts b/app/three/utils/build-meshes.ts index 07679e3..d386d10 100644 --- a/app/three/utils/build-meshes.ts +++ b/app/three/utils/build-meshes.ts @@ -1,14 +1,16 @@ import { BufferAttribute, BufferGeometry, + Color, DoubleSide, Mesh, - MeshStandardMaterial, } from "three"; import { fetchVertices, fetchTriangleIndices, transform } from "./utils"; import { TRIANGLE_INDICES_URL, VERTICES_URL } from "../config"; -import { shaderMaterial } from "../ShaderMaterial"; +import { topoNodeMaterial } from "../ShaderMaterial"; +import { MeshStandardNodeMaterial } from "three/webgpu"; +import { Fn, uniform, vec3, vec4 } from "three/tsl"; interface MappedFeature { featuregeom_id: number; @@ -24,6 +26,8 @@ export async function buildMeshes(mappedFeatures: MappedFeature[]) { const mesh = await buildMesh(layerData); if (layerData.name === "Topography") { mesh.visible = false; + } else { + mesh.visible = true; } meshes.push(mesh); } @@ -58,19 +62,26 @@ async function buildMesh(layerData: MappedFeature) { const indices = new BufferAttribute(indexArray, 1); geometry.setIndex(indices); + geometry.computeVertexNormals(); - const material = new MeshStandardMaterial({ + const material = new MeshStandardNodeMaterial({ color: color, metalness: 0.1, roughness: 0.5, flatShading: true, side: DoubleSide, - wireframe: false, + alphaToCoverage: true, }); + const tColor = uniform(new Color(color)); + const fragmentShader = Fn(() => { + return vec4(tColor.r, tColor.g, tColor.b, 1.0); + }); + material.colorNode = fragmentShader(); + const mesh = new Mesh( geometry, - name === "Topography" ? shaderMaterial : material + name === "Topography" ? topoNodeMaterial : material ); mesh.name = name; mesh.userData.layerId = geomId; diff --git a/app/three/utils/build-scene.ts b/app/three/utils/build-scene.ts index 063eee1..60a37b7 100644 --- a/app/three/utils/build-scene.ts +++ b/app/three/utils/build-scene.ts @@ -1,7 +1,6 @@ import { PerspectiveCamera, Scene, - WebGLRenderer, AmbientLight, DirectionalLight, Group, @@ -19,6 +18,7 @@ import { import { OrbitControls } from "three/addons/controls/OrbitControls.js"; import { getCenter3D, getMaxSize } from "./utils"; +import { WebGPURenderer } from "three/webgpu"; export interface Extent { xmin: number; @@ -30,7 +30,7 @@ export interface Extent { } let controls: OrbitControls; -let renderer: WebGLRenderer; +let renderer: WebGPURenderer; let camera: PerspectiveCamera; let scene: Scene; let overlayCamera: OrthographicCamera; @@ -54,15 +54,15 @@ export function buildScene(container: HTMLElement, extent: Extent) { camera.lookAt(center); // Initialize the renderer - renderer = new WebGLRenderer({ + renderer = new WebGPURenderer({ logarithmicDepthBuffer: true, + antialias: true, }); renderer.setPixelRatio(window.devicePixelRatio); renderer.setSize(width, height); renderer.localClippingEnabled = true; renderer.autoClear = false; - // renderer.setAnimationLoop(animate); // Handle window resize event to adapt the aspect ratio window.addEventListener("resize", () => onWindowResize(container)); @@ -117,7 +117,7 @@ function onWindowResize(container: HTMLElement) { camera.updateProjectionMatrix(); renderer.setSize(container.clientWidth, container.clientHeight); - // required if controls.enableDamping or controls.autoRotate are set to true + // Required if controls.enableDamping or controls.autoRotate are set to true controls.update(); } @@ -152,7 +152,12 @@ function renderOverlay() { ); // Render the overlay scene to the screen (position it in the bottom left) - renderer.setViewport(10, 10, UI_WIDTH, UI_HEIGHT); + renderer.setViewport( + 10, + renderer.domElement.height - UI_HEIGHT - 10, + UI_WIDTH, + UI_HEIGHT + ); renderer.render(overlayScene, overlayCamera); renderer.setViewport( 0, @@ -179,7 +184,7 @@ function buildDefaultLights(scene: Scene, extent: Extent) { lights.push(ambient); // Directional lights - const directionalLight = new DirectionalLight(0xffffff, 1.5); + const directionalLight = new DirectionalLight(0xffffff, 2); directionalLight.position.set( lightPosition.x, lightPosition.y,