diff --git a/src/components/editor/index.tsx b/src/components/editor/index.tsx index 09a7865..4e64291 100644 --- a/src/components/editor/index.tsx +++ b/src/components/editor/index.tsx @@ -25,7 +25,7 @@ const initialNodes: (Node & nodeTypes.All)[] = [ id: 'terrain-out', position: { x: 250, y: 0 }, type: 'terrain', - data: {}, + data: { biome: 'Grassland' }, }, ]; diff --git a/src/components/nodes/terrain-node.tsx b/src/components/nodes/terrain-node.tsx index 4703291..37de0b6 100644 --- a/src/components/nodes/terrain-node.tsx +++ b/src/components/nodes/terrain-node.tsx @@ -1,14 +1,46 @@ -import type { NodeProps } from 'reactflow'; +import { useEffect } from 'react'; +import { useReactFlow, type NodeProps } from 'reactflow'; + +import * as helpers from './helpers'; import * as TerrainGenNode from '@/components/common/terraingen-node'; +import { useGraphGlobals } from '@/hooks/use-graph-globals'; import * as nodeTypes from '@/lib/graph/node-types'; +type TerrainNodeData = nodeTypes.Terrain['data']; const HANDLES = nodeTypes.HANDLES.terrain; -function TerrainNode({ ...props }: NodeProps) { +function TerrainNode({ id, data, ...props }: NodeProps) { + const { setNodes } = useReactFlow(); + const { triggerNodePipelineUpdate } = useGraphGlobals(); + + const onBiomeChange = (biome: TerrainNodeData['biome']) => { + helpers.updateNodeData({ id, setNodes, newData: { biome } }); + }; + + useEffect(() => { + triggerNodePipelineUpdate(id); + }, [data.biome, id, triggerNodePipelineUpdate]); + return ( + + ); } diff --git a/src/lib/graph/index.ts b/src/lib/graph/index.ts index 04314db..f675278 100644 --- a/src/lib/graph/index.ts +++ b/src/lib/graph/index.ts @@ -76,15 +76,49 @@ function generatePipelines( const heightEdgeSourceNode = orderedDependencyNodes.find( (node) => node.id === heightEdge?.source, ); - const outputs: scene.DisplacePipeline['outputs'] = { - height: nodeMapping.getHandleKey({ - // TODO: wow these type assertions are awesome (evil as fuck) - sourceNode: heightEdgeSourceNode!, - outgoingHandleId: heightEdge!.sourceHandle!, - }), - }; - displacePipeline = { instructionSet, uniforms, outputs }; + // Add this check - if no height edge, skip building the pipeline + if (!heightEdge || !heightEdgeSourceNode) { + console.warn('Terrain node requires a height input connection'); + displacePipeline = undefined; + } else { + const waterHeightEdge = edges.find( + (edge) => + edge.target === terrainNode.id && + edge.targetHandle === nodeTypes.HANDLES.terrain.in.waterHeight, + ); + const waterHeightSourceNode = waterHeightEdge + ? orderedDependencyNodes.find((node) => node.id === waterHeightEdge.source) + : undefined; + + const outputs: scene.DisplacePipeline['outputs'] = { + height: nodeMapping.getHandleKey({ + sourceNode: heightEdgeSourceNode, + outgoingHandleId: heightEdge.sourceHandle!, + }), + + waterHeight: + waterHeightSourceNode && waterHeightEdge + ? nodeMapping.getHandleKey({ + sourceNode: waterHeightSourceNode, + outgoingHandleId: waterHeightEdge.sourceHandle!, + }) + : undefined, + + biome: (() => { + const biome = (terrainNode as nodeTypes.Terrain & { id: string }).data.biome; + const biomeMap: Record = { + Grassland: 0, + Desert: 1, + Mountain: 2, + Tundra: 3, + }; + return biomeMap[biome] ?? 0; + })(), + }; + + displacePipeline = { instructionSet, uniforms, outputs }; + } } // find water pipeline @@ -111,6 +145,7 @@ function generatePipelines( sourceNode: heightEdgeSourceNode!, outgoingHandleId: heightEdge!.sourceHandle!, }), + waterHeight: undefined, }; waterPipeline = { instructionSet, uniforms, outputs }; diff --git a/src/lib/graph/node-types.ts b/src/lib/graph/node-types.ts index ca569f5..f9b6713 100644 --- a/src/lib/graph/node-types.ts +++ b/src/lib/graph/node-types.ts @@ -21,7 +21,10 @@ export type Mix = Node<'mix', { nodeType: 'Vec3' | 'Float' }>; export type SmoothstepFloat = Node<'smoothstepFloat'>; export type SmoothstepVec3 = Node<'smoothstepVec3'>; export type VertexData = Node<'vertexData'>; -export type Terrain = Node<'terrain'>; +export type Terrain = Node< + 'terrain', + { biome: 'Grassland' | 'Desert' | 'Mountain' | 'Tundra' } +>; export type Separate = Node<'separate'>; export type Combine = Node<'combine'>; export type Float = Node<'float', { value: number }>; @@ -114,7 +117,10 @@ export const HANDLES = { out: { position: 'vec3-pos-out' }, }, terrain: { - in: { height: 'float-trans-in' }, + in: { + height: 'float-trans-in', + waterHeight: 'float-waterHeight-in', + }, out: {}, }, separate: { @@ -174,7 +180,10 @@ export const NODE_PREFABS: { [nodeType in All['type']]: All & { type: nodeType } }, mix: { type: 'mix', data: { nodeType: 'Float' } }, vertexData: { type: 'vertexData', data: {} }, - terrain: { type: 'terrain', data: {} }, + terrain: { + type: 'terrain', + data: { biome: 'Grassland' }, + }, vector: { type: 'vector', data: { x: 0, y: 0, z: 0 } }, mathFloat: { type: 'mathFloat', diff --git a/src/lib/renderers/terrain-renderer.ts b/src/lib/renderers/terrain-renderer.ts index 3e18df5..c1868dc 100644 --- a/src/lib/renderers/terrain-renderer.ts +++ b/src/lib/renderers/terrain-renderer.ts @@ -78,11 +78,19 @@ export class TerrainRenderer implements IRenderer { private customNodeGraphUniformsBindGroupLayout: GPUBindGroupLayout; private customNodeGraphUniformsBindGroup: GPUBindGroup; + private biomeUniformBuffer: GPUBuffer; + private biomeBindGroupLayout: GPUBindGroupLayout; + private biomeBindGroup: GPUBindGroup; + // custom uniform buffers private nodeGraphUniformBuffer!: GPUBuffer; private nodeGraphUniformLayout!: Map | undefined; private nodeGraphUniformConfig!: scene.DisplacePipeline['uniforms'] | undefined; + private waterHeightUniformBuffer: GPUBuffer; + private waterHeightBindGroupLayout: GPUBindGroupLayout; + private waterHeightBindGroup: GPUBindGroup; + /** Custom displace pipeline, gets reconfigured whenever node structure is changed */ private customDisplacePipeline: GPUComputePipeline; @@ -194,6 +202,68 @@ export class TerrainRenderer implements IRenderer { ], }); + this.biomeUniformBuffer = this.device.createBuffer({ + label: 'biome uniform', + size: 16, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + + const defaultBiome = new Uint32Array([0]); + this.device.queue.writeBuffer(this.biomeUniformBuffer, 0, defaultBiome); + + this.biomeBindGroupLayout = this.device.createBindGroupLayout({ + label: 'biome bind group layout', + entries: [ + { + binding: 0, + visibility: GPUShaderStage.FRAGMENT, + buffer: { type: 'uniform' }, + }, + ], + }); + + this.biomeBindGroup = this.device.createBindGroup({ + label: 'biome bind group', + layout: this.biomeBindGroupLayout, + entries: [ + { + binding: 0, + resource: { buffer: this.biomeUniformBuffer }, + }, + ], + }); + + this.waterHeightUniformBuffer = this.device.createBuffer({ + label: 'water height uniform', + size: 16, // f32 with padding + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + + // Initialize with 0 + this.device.queue.writeBuffer(this.waterHeightUniformBuffer, 0, new Float32Array([0.0])); + + this.waterHeightBindGroupLayout = this.device.createBindGroupLayout({ + label: 'water height bind group layout', + entries: [ + { + binding: 0, + visibility: GPUShaderStage.FRAGMENT, + buffer: { type: 'uniform' }, + }, + ], + }); + + this.waterHeightBindGroup = this.device.createBindGroup({ + label: 'water height bind group', + layout: this.waterHeightBindGroupLayout, + entries: [ + { + binding: 0, + resource: { buffer: this.waterHeightUniformBuffer }, + }, + ], + }); + // initialize depth texture and depth texture view this.depthTexture = this.createDepthTexture({ width: this.webGPU.canvas.width * window.devicePixelRatio, @@ -621,7 +691,11 @@ export class TerrainRenderer implements IRenderer { return this.device.createRenderPipeline({ layout: this.device.createPipelineLayout({ label: 'naive pipeline layout', - bindGroupLayouts: [this.sceneUniformsBindGroupLayout], + bindGroupLayouts: [ + this.sceneUniformsBindGroupLayout, + this.waterHeightBindGroupLayout, + this.biomeBindGroupLayout, + ], }), depthStencil: { depthWriteEnabled: true, @@ -882,6 +956,8 @@ export class TerrainRenderer implements IRenderer { renderPass.setPipeline(this.pipeline); renderPass.setBindGroup(0, this.sceneUniformsBindGroup); + renderPass.setBindGroup(1, this.waterHeightBindGroup); + renderPass.setBindGroup(2, this.biomeBindGroup); renderPass.setVertexBuffer(0, this.stage.groundPlane.vertexBuffer); renderPass.setIndexBuffer(this.stage.groundPlane.indexBuffer!, 'uint32'); renderPass.drawIndexedIndirect(this.stage.groundPlane.indirectBuffer!, 0); @@ -928,6 +1004,26 @@ export class TerrainRenderer implements IRenderer { // also TODO: reuse code between this and our constructor + if (config.outputs.waterHeight) { + console.log('Water height output key:', config.outputs.waterHeight); + + const waterHeightKey = config.outputs.waterHeight.replace('nodeGraphUniforms.', ''); + const waterHeightUniform = config.uniforms.find((u) => u.key === waterHeightKey); + + if ( + waterHeightUniform && + waterHeightUniform.type === 'f32' && + waterHeightUniform.initialValue !== null + ) { + this.setWaterHeightForTerrain(waterHeightUniform.initialValue); + } + } + + if (config.outputs.biome !== undefined) { + const biomeData = new Uint32Array([config.outputs.biome]); + this.device.queue.writeBuffer(this.biomeUniformBuffer, 0, biomeData); + } + const customComputeShader = jit.generateDisplaceShaderCode( config, displaceComputeShaderTemplate, @@ -1176,6 +1272,7 @@ export class TerrainRenderer implements IRenderer { setMeshUniforms(size: number, resolution: number) { this.stage.groundPlane.updateUniforms(this.device, size, resolution); + this.stage.waterPlane.updateUniforms(this.device, size, resolution); const encoder = this.device.createCommandEncoder(); this.runComputes(encoder); @@ -1216,4 +1313,9 @@ export class TerrainRenderer implements IRenderer { this.device.queue.submit([encoder.finish()]); console.log('rerunning compute after uniform update'); } + + setWaterHeightForTerrain(height: number) { + this.device.queue.writeBuffer(this.waterHeightUniformBuffer, 0, new Float32Array([height])); + console.log('Updated water height for terrain fragment shader:', height); + } } diff --git a/src/lib/shaders/jit/types/shaders.ts b/src/lib/shaders/jit/types/shaders.ts index d2750a0..1cbccbd 100644 --- a/src/lib/shaders/jit/types/shaders.ts +++ b/src/lib/shaders/jit/types/shaders.ts @@ -13,6 +13,8 @@ type ShaderConfigBase = { export type DisplaceShaderConfig = ShaderConfigBase & { outputs: { height: util.ReferenceKey; + waterHeight?: util.ReferenceKey; + biome?: number; }; }; diff --git a/src/lib/shaders/naive.fs.wgsl b/src/lib/shaders/naive.fs.wgsl index cdc4fc9..eb1cd04 100644 --- a/src/lib/shaders/naive.fs.wgsl +++ b/src/lib/shaders/naive.fs.wgsl @@ -10,6 +10,131 @@ struct FragmentInput @location(3) shadow_pos: vec3f, } +struct BiomeUniforms { + biomeType: u32, +} + +struct WaterHeightUniforms { + height: f32, +} + +@group(0) @binding(0) var camera : CameraUniforms; +@group(1) @binding(0) var waterHeight : WaterHeightUniforms; +@group(2) @binding(0) var biome : BiomeUniforms; + +// Simple hash function for noise +fn hash(p: vec2f) -> f32 { + let p3 = fract(vec3f(p.x, p.y, p.x) * 0.13); + let dot_product = dot(p3, vec3f(p3.y, p3.z, p3.x) + 3.333); + return fract((p3.x + p3.y) * dot_product); +} + +// thanks copilot +fn noise(p: vec2f) -> f32 { + let i = floor(p); + let f = fract(p); + + // Cubic interpolation + let u = f * f * (3.0 - 2.0 * f); + + // Sample corners + let a = hash(i); + let b = hash(i + vec2f(1.0, 0.0)); + let c = hash(i + vec2f(0.0, 1.0)); + let d = hash(i + vec2f(1.0, 1.0)); + + // Interpolate + return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); +} + +fn fbm(p: vec2f) -> f32 { + var value = 0.0; + var amplitude = 0.5; + var frequency = 1.0; + var pos = p; + + for (var i = 0; i < 4; i++) { + value += amplitude * noise(pos * frequency); + frequency *= 2.0; + amplitude *= 0.5; + } + + return value; +} + +fn celShade(value: f32, bands: f32) -> f32 { + return floor(value * bands) / bands; +} + +fn grassTexture(pos: vec3f) -> vec3f { + let noise1 = fbm(pos.xz * 8.0); + let noise2 = noise(pos.xz * 2.0); + + let combinedNoise = noise1 * 0.6 + noise2 * 0.4; + + let grassBright = vec3f(0.45, 0.85, 0.15); + let grassMid = vec3f(0.25, 0.65, 0.10); + let grassDark = vec3f(0.15, 0.45, 0.05); + + let t1 = smoothstep(0.3, 0.5, combinedNoise); + let t2 = smoothstep(0.4, 0.8, combinedNoise); + + var grassColor = grassDark; + grassColor = mix(grassColor, grassMid, t1); + grassColor = mix(grassColor, grassBright, t2); + + return grassColor; +} + +fn sandTexture(pos: vec3f) -> vec3f { + let sandScale = 20.0; + let sandNoise = fbm(pos.xz * sandScale); + + let sandBase = vec3f(0.95, 0.88, 0.71); + let sandDark = vec3f(0.85, 0.78, 0.61); + + return mix(sandDark, sandBase, sandNoise); +} + +fn snowTexture(pos: vec3f) -> vec3f { + let sandScale = 20.0; + let sandNoise = fbm(pos.xz * sandScale); + + let sandBase = vec3f(0.94, 0.99, 1.0); + let sandDark = vec3f(0.812, 0.953, 0.969); + + return mix(sandDark, sandBase, sandNoise); +} + +fn mountainTexture(pos: vec3f) -> vec3f { + let noise1 = fbm(pos.xz * 3.0); + let noise2 = noise(pos.xz * 10.0); + let noise3 = noise(pos.xz * 30.0); + + let combinedNoise = noise1 * 0.5 + noise2 * 0.3 + noise3 * 0.2; + + let rockDark = vec3f(0.35, 0.35, 0.38); + let rockMid = vec3f(0.50, 0.48, 0.45); + let rockLight = vec3f(0.65, 0.62, 0.58); + + let t1 = smoothstep(0.25, 0.45, combinedNoise); + let t2 = smoothstep(0.55, 0.75, combinedNoise); + + var rockColor = rockDark; + rockColor = mix(rockColor, rockMid, t1); + rockColor = mix(rockColor, rockLight, t2); + + let snowStart = 1.0; + let snowFull = 1.5; + let snowTint = vec3f(0.95, 0.96, 0.98); + + let snowAmount = smoothstep(snowStart, snowFull, pos.y); + + rockColor = mix(rockColor, snowTint, snowAmount); + + return rockColor; +} + @group(0) @binding(1) var directionalLightUniforms: DirectionalLightUniforms; @group(0) @binding(2) var shadow_map: texture_depth_2d; @group(0) @binding(3) var shadow_sampler: sampler; @@ -17,21 +142,84 @@ struct FragmentInput @fragment fn main(in: FragmentInput) -> @location(0) vec4f { - let shadowSample = textureSample(shadow_map, shadow_sampler, in.shadow_pos.xy); - let isShadowed = shadowSample < in.shadow_pos.z - shadowBias; + let biomeType = biome.biomeType; + var baseColor: vec3f; + let underwater = vec3f(0.2, 0.3, 0.4); + + if (biomeType == 0) { + // grassland + let sandScale = 20.0; + let sandNoise = fbm(vec2f(in.pos.x, in.pos.z) * sandScale); + + let grass = grassTexture(in.pos); + let sand = sandTexture(in.pos); + + let grassStart = waterHeight.height + 0.8; + let sandEnd = waterHeight.height + 0.4; + + baseColor = grass; + if (waterHeight.height > in.pos.y) { + baseColor = underwater; + } else if (in.pos.y <= sandEnd) { + baseColor = sand; + } else if (in.pos.y <= grassStart) { + let t = (in.pos.y - sandEnd) / (grassStart - sandEnd); + baseColor = mix(sand, grass, t); + } else { + baseColor = grass; + } + } else if (biomeType == 1) { + // desert + let sandScale = 20.0; + let sandNoise = fbm(vec2f(in.pos.x, in.pos.z) * sandScale); + + let sand = sandTexture(in.pos); + + baseColor = sand; + if (waterHeight.height > in.pos.y) { + baseColor = underwater; + } else { + baseColor = sand; + } + } else if (biomeType == 2) { + // mountain + baseColor = mountainTexture(in.pos); + + if (waterHeight.height > in.pos.y) { + baseColor = underwater; + } + } else if (biomeType == 3) { + // tundra + let sandScale = 20.0; + let sandNoise = fbm(vec2f(in.pos.x, in.pos.z) * sandScale); + + let snow = snowTexture(in.pos); + + baseColor = vec3f(1.0, 1.0, 1.0); + if (waterHeight.height > in.pos.y) { + baseColor = underwater; + } else { + baseColor = snow; + } + } + else { + // Default to grass texture + baseColor = grassTexture(in.pos); + } - // do lambertian shading - let lightDir = normalize(directionalLightUniforms.lightDir); - var directionalLightStrength = max(dot(in.nor, lightDir), 0.0); - if isShadowed { - directionalLightStrength = mix(directionalLightStrength, 0.0, 0.95); - } - let directLight = vec3f(1.0, 0.95, 0.8) * directionalLightStrength; + let shadowSample = textureSample(shadow_map, shadow_sampler, in.shadow_pos.xy); + let isShadowed = shadowSample < in.shadow_pos.z - shadowBias; - let ambientLight = vec3f(0.1, 0.1, 0.2); + // do lambertian shading + let lightDir = normalize(directionalLightUniforms.lightDir); + var directionalLightStrength = max(dot(in.nor, lightDir), 0.0); + if (isShadowed) { + directionalLightStrength = mix(directionalLightStrength, 0.0, 0.95); + } + let directLight = vec3f(1.0, 0.95, 0.8) * directionalLightStrength; - let baseColor = vec3f(0.8, 0.8, 0.8); + let ambientLight = vec3f(0.1, 0.1, 0.2); - var color = baseColor * (directLight + ambientLight); - return vec4f(color, 1.0); + var color = baseColor * (directLight + ambientLight); + return vec4f(color, 1.0); }