Appearance
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.asmdefDependencies
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 required —
RaycastTerrainSamplerneeds collidable geometry. If your terrain tool renders without colliders, write a custom sampler against its API instead.