Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
},
"dependencies": {
"react": "^19.2.7",
"react-dom": "^19.2.7"
"react-dom": "^19.2.7",
"three": "^0.186.0"
},
"devDependencies": {
"@testing-library/dom": "^10.4.1",
Expand All @@ -21,6 +22,7 @@
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/three": "^0.185.4",
"@vitejs/plugin-react": "^6.0.3",
"jsdom": "^29.1.1",
"oxlint": "^1.71.0",
Expand Down
53 changes: 53 additions & 0 deletions web/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import TerrainView from './TerrainView'

function App() {
return (
<main>
<h1>terrain-diffusion</h1>
<p>The visualizer goes here.</p>
<TerrainView />
</main>
)
}
Expand Down
69 changes: 69 additions & 0 deletions web/src/TerrainView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { useEffect, useRef } from 'react'
import {
type DataTexture,
Mesh,
MeshBasicMaterial,
OrthographicCamera,
PlaneGeometry,
Scene,
WebGLRenderer,
} from 'three'
import { frameCamera } from './camera'
import { heightTexture } from './terrain'
import { requestTerrain, type TerrainRequest } from './terrainSource'

const REGION: TerrainRequest = { seed: 1, row: 0, column: 0, rows: 32, columns: 32 }

function TerrainView() {
const containerRef = useRef<HTMLDivElement>(null)

useEffect(() => {
const container = containerRef.current!
const renderer = new WebGLRenderer()
renderer.setPixelRatio(window.devicePixelRatio)
container.appendChild(renderer.domElement)

const scene = new Scene()
const camera = new OrthographicCamera()
camera.position.z = 1

const plane = new Mesh(new PlaneGeometry(1, 1), new MeshBasicMaterial())
scene.add(plane)

const draw = () => {
const { clientWidth: width, clientHeight: height } = container
renderer.setSize(width, height)
frameCamera(camera, width, height)
renderer.render(scene, camera)
}

const observer = new ResizeObserver(draw)
observer.observe(container)

let texture: DataTexture | undefined
let showing = true

requestTerrain(REGION).then(({ request, heights }) => {
if (!showing) return

texture = heightTexture(heights, request.columns, request.rows)
plane.material.map = texture
plane.material.needsUpdate = true
draw()
})

return () => {
showing = false
observer.disconnect()
texture?.dispose()
plane.geometry.dispose()
plane.material.dispose()
renderer.dispose()
container.removeChild(renderer.domElement)
}
}, [])

return <div ref={containerRef} className="terrain-view" />
}

export default TerrainView
12 changes: 12 additions & 0 deletions web/src/camera.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { OrthographicCamera } from 'three'

export function frameCamera(camera: OrthographicCamera, width: number, height: number) {
const aspect = width / height
const halfHeight = aspect >= 1 ? 0.5 : 0.5 / aspect

camera.left = -halfHeight * aspect
camera.right = halfHeight * aspect
camera.top = halfHeight
camera.bottom = -halfHeight
camera.updateProjectionMatrix()
}
44 changes: 44 additions & 0 deletions web/src/colours.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
export type Colour = [number, number, number]

export const MIN_HEIGHT = 0
export const MAX_HEIGHT = 255

const STOPS: { height: number; colour: Colour }[] = [
{ height: 0, colour: [12, 44, 92] },
{ height: 80, colour: [60, 120, 180] },
{ height: 100, colour: [214, 198, 138] },
{ height: 150, colour: [74, 128, 64] },
{ height: 200, colour: [120, 104, 88] },
{ height: 255, colour: [248, 248, 252] },
]

function mix(from: Colour, to: Colour, along: number): Colour {
return [0, 1, 2].map((channel) =>
Math.round(from[channel] + (to[channel] - from[channel]) * along),
) as Colour
}

export function heightColour(height: number): Colour {
const clamped = Math.min(Math.max(height, MIN_HEIGHT), MAX_HEIGHT)

for (let i = 1; i < STOPS.length; i++) {
const start = STOPS[i - 1]
const end = STOPS[i]
if (clamped <= end.height) {
return mix(start.colour, end.colour, (clamped - start.height) / (end.height - start.height))
}
}

return STOPS[STOPS.length - 1].colour
}

export function gridPixels(heights: ArrayLike<number>): Uint8Array {
const pixels = new Uint8Array(heights.length * 4)

for (let i = 0; i < heights.length; i++) {
pixels.set(heightColour(heights[i]), i * 4)
pixels[i * 4 + 3] = 255
}

return pixels
}
14 changes: 10 additions & 4 deletions web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,18 @@
color-scheme: light dark;
}

html,
body,
#root,
main,
.terrain-view {
height: 100%;
}

body {
margin: 0;
}

main {
max-width: 40rem;
margin: 0 auto;
padding: 2rem 1rem;
.terrain-view canvas {
display: block;
}
12 changes: 12 additions & 0 deletions web/src/terrain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { DataTexture, NearestFilter, SRGBColorSpace } from 'three'
import { gridPixels } from './colours'

export function heightTexture(heights: ArrayLike<number>, width: number, height: number) {
const texture = new DataTexture(gridPixels(heights), width, height)
texture.magFilter = NearestFilter
texture.minFilter = NearestFilter
texture.colorSpace = SRGBColorSpace
texture.needsUpdate = true

return texture
}
Loading
Loading