Skip to content

kids.kapish.terrains

unity

Terrain-agnostic primitives for sampling and conforming to terrain surfaces.

Installation

Add to your Unity project's package manifest:

json
{
  "kids.kapish.terrains": "file:../../src/unity/kids.kapish.terrains"
}

Requires Unity 6000.0+.

Architecture

Why an abstraction

Anything that needs to "sit on" the terrain — spline paths, decals, prop scatter, footstep queries — should not care how the surface is found. ITerrainSampler is that seam. A consumer takes an ITerrainSampler and calls TrySampleSurface; it neither knows nor cares whether the answer came from a physics raycast, a heightmap lookup, or a procedural terrain tool's API.

This keeps higher-level packages (e.g. kids.kapish.splines) free of any paid-asset or pipeline dependency. Swapping terrain technology is a one-line change at the call site, not a refactor.

RaycastTerrainSampler

The default, dependency-free implementation. It raycasts straight down from above the reference position through a LayerMask. It works against any collider, which means it is correct for Unity Terrain, mesh terrain, and most third-party terrain tools out of the box — at the cost of requiring colliders and a physics query per sample.

A tool-specific sampler (for example one that reads a terrain heightmap directly) belongs in a separate package that depends on both this package and the terrain tool, so the core abstraction stays clean.

File Structure

Runtime/
├── ITerrainSampler.cs        # Surface query abstraction
├── RaycastTerrainSampler.cs  # Generic physics-raycast sampler
└── Carrot.Terrains.asmdef

Dependencies

None beyond the Unity engine.

Build

Import via Unity Package Manager. Requires Unity 6000.0+.


Usage Guide

Terrain-agnostic primitives for sampling and conforming to terrain surfaces.

Sampling a surface

csharp
using Carrot.Terrains;
using UnityEngine;

// Cast down through the "Terrain" layer, up to 500 units.
ITerrainSampler sampler = new RaycastTerrainSampler(LayerMask.GetMask("Terrain"), 500f);

if (sampler.TrySampleSurface(worldPosition, out Vector3 point, out Vector3 normal))
{
    // Snap an object to the ground and align it to the slope.
    transform.position = point;
    transform.rotation = Quaternion.FromToRotation(Vector3.up, normal);
}

Plugging into a consumer

Consumers should accept an ITerrainSampler rather than constructing one, so callers can choose the implementation:

csharp
using Carrot.Terrains;

public void ConformToGround(ITerrainSampler sampler)
{
    if (sampler.TrySampleSurface(transform.position, out var p, out _))
        transform.position = p;
}

Custom sampler

Implement ITerrainSampler to back the query with something other than physics — a heightmap, a terrain tool's API, or a baked surface cache:

csharp
using Carrot.Terrains;
using UnityEngine;

public sealed class HeightmapTerrainSampler : ITerrainSampler
{
    private readonly Terrain _terrain;

    public HeightmapTerrainSampler(Terrain terrain) => _terrain = terrain;

    public bool TrySampleSurface(Vector3 referencePosition, out Vector3 surfacePoint, out Vector3 surfaceNormal)
    {
        float h = _terrain.SampleHeight(referencePosition) + _terrain.transform.position.y;
        surfacePoint = new Vector3(referencePosition.x, h, referencePosition.z);
        surfaceNormal = _terrain.terrainData.GetInterpolatedNormal(
            Mathf.InverseLerp(_terrain.transform.position.x, _terrain.transform.position.x + _terrain.terrainData.size.x, referencePosition.x),
            Mathf.InverseLerp(_terrain.transform.position.z, _terrain.transform.position.z + _terrain.terrainData.size.z, referencePosition.z));
        return true;
    }
}

Tips

  • Raycast distance — set it generously; the sampler offsets the ray origin upward so the cast starts above the reference point. Too short and steep terrain near the edges will miss.
  • Layer mask — exclude characters, props, and triggers so the sampler only sees the ground you mean.
  • Colliders requiredRaycastTerrainSampler needs collidable geometry. If your terrain tool renders without colliders, write a custom sampler against its API instead.

Carrot