Terrain Editor

Building Aetherforge Terrain as a Real-Time 3D Map Editor

Aetherforge Terrain is a browser-based terrain editor, but I did not approach it like a static scene viewer. I built it more like a real tool: editable terrain, render profiles, weather, water, hex mode, scatter generation, undo/redo, project export, and a persistent settings layer that remembers how I like to work.

The stack is modern but focused. The app uses React, React Three Fiber, Drei, custom shaders, and Zustand. The point was not just to render a pretty scene. The point was to make a terrain workbench that feels interactive, tunable, and fast enough to iterate on in the browser.

What I Built

The repo has four core layers:

  • A React shell in App.tsx that manages editing state, view mode, environmental controls, and export flows.
  • A Terrain component that owns the editable geometry, object scattering, history stack, and serialization.
  • A rendering/settings layer built around custom shader uniforms plus a persisted Zustand store.
  • Scene helpers like WeatherSystem, Water, HexTerrain, SunControl, and DebugOverlay.

That split let me keep editing concerns, render concerns, and scene presentation concerns from collapsing into one giant monolith.

The App Shell Orchestrates the Tool

App.tsx is the orchestration layer. It does not try to perform the terrain edits directly. Instead, it holds the editor state and pushes signals down into the scene.

That includes:

  • brush settings
  • biome settings
  • scatter settings
  • render and performance settings
  • wind, sun, weather, and water controls
  • camera mode
  • hex mode
  • undo/redo triggers
  • export/save/snapshot triggers

The settings footprint is big, but the structure is still clear because the top-level app treats the editor like a tool with explicit modes and signals.

Project export is a good example:

const handleProjectSnapshot = (map: MapData) => {
  const payload = {
    type: 'aetherforge-project',
    version: 1,
    map,
    settings: {
      brush,
      renderSettings,
      perfSettings,
      biomeSettings,
      scatterSettings,
      generationSettings,
      gridConfig,
      windSettings,
      renderProfileId,
      sunControl,
      sunManual,
      timeOfDay,
      weather,
      waterLevel,
      isOrtho,
      hexMode,
      showDebug,
      perfMode
    }
  };
  const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
  const url = URL.createObjectURL(blob);
  const link = document.createElement('a');
  link.href = url;
  link.download = `aetherforge-project-${Date.now()}.json`;
  link.click();
  URL.revokeObjectURL(url);
};

That is an important design choice. I did not want export to mean “just dump vertex positions.” I wanted a real project artifact that captures both the terrain and the editor state that produced it.

Terrain Editing Lives in the Terrain Component

The actual editing logic is concentrated in components/Terrain.tsx. That component owns:

  • the shader-backed terrain mesh
  • vertex height changes
  • biome color painting
  • water level interaction
  • object placement and erasing
  • scatter generation
  • undo/redo history
  • save and snapshot serialization

One piece I like here is the history system. Instead of trying to diff every edit, I keep bounded snapshots of geometry colors and object placement:

const saveState = () => {
  if (!meshRef.current) return;
  const geometry = meshRef.current.geometry;
  const positions = geometry.attributes.position.array as Float32Array;
  const colors = geometry.attributes.color.array as Float32Array;

  const posCopy = new Float32Array(positions);
  const colCopy = new Float32Array(colors);

  if (historyIndexRef.current < historyRef.current.length - 1) {
    historyRef.current = historyRef.current.slice(0, historyIndexRef.current + 1);
  }

  historyRef.current.push({
    positions: posCopy,
    colors: colCopy,
    trees: [...trees],
    rocks: [...rocks],
    ruins: [...ruins]
  });
};

For this kind of editor, that is the right tradeoff. It is simple, predictable, and easy to reason about when undo/redo bugs show up.

Shader-Driven Terrain Instead of Flat Materials

The terrain look is not coming from stock Three.js materials. I built a custom shader layer so the terrain could respond to height, slope, painted biome weights, grid overlays, weather, and fast-mode toggles.

The uniform surface tells the story:

const uniforms = useMemo(() => {
  return {
    ...TERRAIN_UNIFORMS,
    uBrushRadius: { value: brush.radius },
    uBrushPos: { value: new THREE.Vector2(0, 0) },
    uBrushShape: { value: 0.0 },
    uIsHovering: { value: 0.0 },
    uTime: { value: 0 },
    uSunDirection: { value: new THREE.Vector3() },
    uWeatherMode: { value: 0 },
    uWaterLevel: { value: waterLevel },
    uOpacity: { value: 1.0 },
    uRoughness: { value: renderSettings.roughness },
    uMinRoughness: { value: renderSettings.minRoughness },
    uSpecularStrength: { value: renderSettings.specular },
    uAmbient: { value: renderSettings.ambient },
    uLightIntensity: { value: renderSettings.lightIntensity },
    uSnowHeight: { value: biomeSettings.snowHeight },
    uRockSlope: { value: biomeSettings.rockSlope },
    uSandLevel: { value: biomeSettings.sandLevel },
    uShowGrid: { value: gridConfig.visible ? 1.0 : 0.0 }
  };
}, []);

That gave me a useful balance between art direction and tool behavior. The same terrain mesh can look soft and painterly, or stripped down and fast, without changing the underlying editor model.

The fragment shader itself mixes slope, elevation, biome paint, and noise instead of relying on a single texture:

float sandThresh = uWaterLevel + uSandLevel + (macroNoise * 1.5);
float sandMix = smoothstep(sandThresh + 0.8, sandThresh - 0.2, height);
finalAlbedo = mix(finalAlbedo, cSand, sandMix);

float snowThresh = uSnowHeight + (macroNoise * 4.0);
float snowMix = smoothstep(snowThresh - 2.0, snowThresh, height);
finalAlbedo = mix(finalAlbedo, cSnow, snowMix);

if (vBiomeWeights.r > 0.0) {
    finalAlbedo = mix(finalAlbedo, cDirt, vBiomeWeights.r);
}
if (vBiomeWeights.a > 0.0) {
    finalAlbedo = mix(finalAlbedo, rockBase, vBiomeWeights.a);
}

That is exactly the kind of shader logic I wanted: enough richness to make the terrain interesting, but still understandable and editable from tool-side controls.

Persisted Render Profiles

One problem with graphics-heavy browser tools is that they often assume every machine should run the same settings. I did not want that here.

Instead, I built explicit render/performance profiles and persisted them with Zustand:

export const useRenderSettingsStore = create<RenderSettingsState>()(
  persist(
    (set, get) => ({
      profileId: 'medium',
      ...applyRenderProfile('medium'),
      overrides: {},
      setProfile: (profileId) => {
        if (profileId === 'custom') {
          set({ profileId: 'custom' });
          return;
        }
        const next = applyRenderProfile(profileId);
        set({
          profileId,
          renderSettings: next.renderSettings,
          perfSettings: next.perfSettings,
          overrides: {}
        });
      }
    }),
    { name: 'aetherforge-render-settings-v1', version: 3 }
  )
);

The profiles themselves are opinionated and practical:

  • ultraLow
  • low
  • medium
  • high
  • cinematic
  • dndBaked
  • custom

That matters because a terrain editor has two valid use cases:

  • authoring quickly on constrained hardware
  • pushing for nicer presentation when taking exports or screenshots

The profile system acknowledges that directly instead of pretending one setting preset fits everything.

Hex Mode, Scatter, and Scene Dressing

I also wanted the editor to support more than one visual language. That is why there is a separate HexTerrain path in addition to the continuous terrain mesh. Hex mode is not a gimmick here. It is a second representation of the same terrain space with its own instanced rendering and prop decoration strategy.

HexTerrain.tsx leans heavily on instancing and precomputed offsets:

const DETAIL_OFFSETS: {x: number, z: number, scale: number, rot: number, id: number}[][] = [];
const OFFSETS_PER_HEX = 6;
for (let i = 0; i < 5000; i++) {
    const offsets = [];
    for (let j = 0; j < OFFSETS_PER_HEX; j++) {
        const angle = Math.random() * Math.PI * 2;
        const rad = Math.sqrt(Math.random()) * 0.5;
        offsets.push({
            x: Math.cos(angle) * rad,
            z: Math.sin(angle) * rad,
            scale: 0.6 + Math.random() * 0.6,
            rot: Math.random() * Math.PI * 2,
            id: Math.random()
        });
    }
    DETAIL_OFFSETS.push(offsets);
}

That approach keeps the scene visually busy without turning every prop into an individually managed object with a heavy runtime cost.

Weather, Water, and Environmental Feedback

The environment layer is intentionally dynamic. Weather is not just a dropdown label. WeatherSystem.tsx actually spawns and animates particle fields for rain and snow:

useFrame((state) => {
  if (!meshRef.current || isDisabled) return;

  const positions = meshRef.current.geometry.attributes.position.array as Float32Array;
  const velocities = meshRef.current.geometry.attributes.velocity.array as Float32Array;
  const time = state.clock.getDelta();
  const fallSpeed = mode === WeatherMode.RAIN ? 60.0 : 10.0;

  for (let i = 0; i < count; i++) {
    const speed = velocities[i];
    positions[i * 3 + 1] -= speed * fallSpeed * time;
    positions[i * 3] += 10.0 * time * speed;

    if (positions[i * 3 + 1] < 0) {
      positions[i * 3 + 1] = 60 + Math.random() * 20;
      positions[i * 3] = (Math.random() - 0.5) * 150;
      positions[i * 3 + 2] = (Math.random() - 0.5) * 150;
    }
  }
});

Again, the goal was to make the tool feel alive enough that lighting, weather, and water choices actually matter while you are shaping the terrain.

Tests That Match the Architecture

The tests in this repo focus on the stable parts of the design:

  • render profile composition
  • persisted render settings behavior
  • type contracts
  • utility functions
  • shader-related expectations

That is the right emphasis for a tool like this. The geometry editing itself is highly interactive, but the configuration and rendering-policy layer should stay predictable. The tests reinforce that boundary.

Why I Like This Repo

What I like most about Aetherforge Terrain is that it behaves like an editor, not just a Three.js demo.

It has:

  • real editing modes
  • real export paths
  • stateful render profiles
  • environmental controls
  • performance fallbacks
  • history management
  • two terrain presentation models

That combination gives the project a clear identity. It is part terrain sculptor, part diorama renderer, and part browser-native technical tool. From an engineering standpoint, that is what makes it satisfying: the visuals matter, but the structure behind the visuals matters just as much.


Leave a Reply

Your email address will not be published. Required fields are marked *