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 9f8ab8f..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 @@ -400,7 +398,7 @@ export class SceneView extends EventTarget { this._resetClippingBox(); } - for (let i = 0; i < this._model.children.length; i++) { + for (let i = 1; i < this._model.children.length; i++) { const mesh = this._model.children[i]; if (explode) { @@ -452,14 +450,13 @@ async function init(container: HTMLElement, modelId = MODEL_ID) { const { renderer, scene, camera, controls } = buildScene(container, extent); - // Start render loop - renderer.setAnimationLoop(animate(() => {})); - // Build the 3D model - const model = new Group(); + const meshes = await buildMeshes(mappedFeatures); + const model = new ClippingGroup(); + model.enabled = false; + model.add(...meshes); model.name = "geologic-model"; scene.add(model); - await buildMeshes(mappedFeatures, model); // Add a coordinate grid to the scene const { gridHelper, annotations } = buildCoordinateGrid(extent); @@ -491,11 +488,14 @@ async function init(container: HTMLElement, modelId = MODEL_ID) { map.visible = false; scene.add(map); - // Update render loop to include topography const topography = scene.getObjectByName("Topography") as Mesh; - renderer.setAnimationLoop( - animate(rendererCallback(camera, renderer, scene, map, extent, topography)) - ); + if (topography) { + renderer.setAnimationLoop( + animate( + rendererCallback(camera, renderer, scene, map, extent, topography) + ) + ); + } return { scene, @@ -509,14 +509,15 @@ async function init(container: HTMLElement, modelId = MODEL_ID) { function rendererCallback( camera: PerspectiveCamera, - renderer: WebGLRenderer, + renderer: WebGPURenderer, scene: Scene, map: MapView, extent: Extent, - topography: Mesh | undefined + topography: Mesh ) { return () => { - if (topography && topography.visible) { + 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..fb50352 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)), + () => { + const 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..ec5a083 100644 --- a/app/three/utils/build-clipping-planes.ts +++ b/app/three/utils/build-clipping-planes.ts @@ -8,8 +8,6 @@ import { LineBasicMaterial, LineSegments, Mesh, - MeshBasicMaterial, - MeshStandardMaterial, Object3DEventMap, PerspectiveCamera, Plane, @@ -17,11 +15,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 { color } from "three/tsl"; export enum Orientation { X = "X", @@ -32,7 +36,7 @@ export enum Orientation { NZ = "NZ", } -type PlaneMesh = Mesh; +type PlaneMesh = Mesh; type EdgeMesh = LineSegments< EdgesGeometry, LineBasicMaterial, @@ -49,7 +53,7 @@ let currentExtent: Extent; const BUFFER = 500; export function buildClippingplanes( - renderer: WebGLRenderer, + renderer: WebGPURenderer, camera: PerspectiveCamera, orbitControls: OrbitControls, extent: Extent, @@ -148,7 +152,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 +194,25 @@ export function buildClippingplanes( edgeMeshMap[p.orientation] = edges; } + for (const p of planesData) { + // Create ClippingGroup for each cap mesh face + const capMeshGroupName = `cap-mesh-group-${p.orientation}`; + let capMeshGroup = scene.getObjectByName(capMeshGroupName) as ClippingGroup; + if (capMeshGroup) { + capMeshGroup.clear(); + } else { + capMeshGroup = new ClippingGroup(); + capMeshGroup.name = capMeshGroupName; + scene.add(capMeshGroup); + } + + // Set clipping planes for the cap meshes + const capMeshGroupPlanes = planes.filter( + (plane) => plane.normal.dot(p.normal) !== 1 + ); + capMeshGroup.clippingPlanes = capMeshGroupPlanes; + } + // Add meshes to the scene const planeMeshGroup = new Group(); planeMeshGroup.name = "clipping-planes"; @@ -388,29 +411,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 +591,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,60 +653,32 @@ 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 || - orientation === Orientation.NZ - ? 1 - : -1; - - const color = - mesh.material instanceof MeshStandardMaterial + const colorThree = + mesh.material instanceof MeshStandardNodeMaterial ? mesh.material.color - : new Color(1, 1, 1); + : new Color(0.1, 0.1, 0.1); - const material = new MeshStandardMaterial({ - color, + const material = new MeshStandardNodeMaterial({ side: DoubleSide, - metalness: 0.0, - roughness: 1.0, flatShading: true, - polygonOffset: true, - polygonOffsetFactor: offset, - polygonOffsetUnits: offset, - clippingPlanes, wireframe: scene.userData.wireframe, + alphaToCoverage: true, }); - const localMeshes = polygons.map((polygon) => { + material.colorNode = color(colorThree.r, colorThree.g, colorThree.b); + + polygons.forEach((polygon) => { const geometry = triangulatePolygon(polygon, plane); const capMesh = new Mesh(geometry, material); capMesh.visible = mesh.visible; capMesh.name = mesh.name; - // Offset mesh to avoid flickering - const normal = plane.normal.clone().multiplyScalar(offset); - - const positionAttr = capMesh.geometry.attributes.position; - for (let i = 0; i < positionAttr.count; i++) { - const x = positionAttr.getX(i) + normal.x; - const y = positionAttr.getY(i) + normal.y; - const z = positionAttr.getZ(i) + normal.z; - positionAttr.setXYZ(i, x, y, z); + if (capMesh && geometry.index && geometry.index.count > 0) { + capMeshGroup.add(capMesh); } - positionAttr.needsUpdate = true; - - return 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 a5bbe37..04d04c0 100644 --- a/app/three/utils/build-meshes.ts +++ b/app/three/utils/build-meshes.ts @@ -1,15 +1,10 @@ -import { - BufferAttribute, - BufferGeometry, - DoubleSide, - Group, - Mesh, - MeshStandardMaterial, -} from "three"; +import { BufferAttribute, BufferGeometry, DoubleSide, Mesh } 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 { color } from "three/tsl"; interface MappedFeature { featuregeom_id: number; @@ -18,22 +13,24 @@ interface MappedFeature { preview: { legend_color: string; legend_text: string }; } -export async function buildMeshes( - mappedFeatures: MappedFeature[], - model: Group -) { - for (const mappedFeature of mappedFeatures) { - const mesh = await buildMesh(mappedFeature); - if (mappedFeature.name === "Topography") { +export async function buildMeshes(mappedFeatures: MappedFeature[]) { + const meshes = []; + for (let i = 0; i < mappedFeatures.length; i++) { + const layerData = mappedFeatures[i]; + const mesh = await buildMesh(layerData); + if (layerData.name === "Topography") { mesh.visible = false; + } else { + mesh.visible = true; } - - model.add(mesh); + meshes.push(mesh); } + + return meshes; } async function buildMesh(layerData: MappedFeature) { - const color = `#${layerData.preview.legend_color}`; + const colorHex = `#${layerData.preview.legend_color}`; const name = layerData.preview.legend_text; const geomId = layerData.featuregeom_id.toString(); @@ -59,19 +56,23 @@ async function buildMesh(layerData: MappedFeature) { const indices = new BufferAttribute(indexArray, 1); geometry.setIndex(indices); + geometry.computeVertexNormals(); - const material = new MeshStandardMaterial({ - color: color, + const material = new MeshStandardNodeMaterial({ + color: colorHex, metalness: 0.1, roughness: 0.5, flatShading: true, side: DoubleSide, - wireframe: false, + alphaToCoverage: true, }); + // Required by ClippingGroup otherwise clipping does not work + material.colorNode = color(colorHex); + 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,