Ray, Plane & Noise

Ray and Plane are pure math for turning a screen tap into a point on the ground or on a tilted surface; Noise is a Perlin/fractal sampler for smooth random values (heights, wobble, organic motion). To hit real objects instead of a mathematical plane, use Physics.raycast (it takes a Ray's origin/dir) — a Plane blocks nothing.

  • place an object where the user taps on the ground
  • turn a screen tap into a 3D ray from the camera
  • intersect a ray with a ground plane or a tilted surface
  • keep a node on or above a sloped surface
  • make a node bob and sway with smooth random motion
  • vary heights smoothly with Perlin noise

Platforms: Ray and Plane on every host. Noise: desktop, the le.codes viewer app and headless tests; on an iOS / Android shell, the web and the web player only in an app that also creates a 3D Scene (no 3D engine is loaded otherwise; on iOS / Android new Noise() throws).

Place an object where the user taps the ground: getRay + Plane {#at-a-glance}

A tap becomes a camera ray, the ray meets a ground plane, the marker moves to the hit point.

TypeScript
const scene = new Scene()
const marker = Mesh.sphere({ radius: 0.1, material: Material.lit({ color: '#fc0' }) })
scene.add(Light.sun(), marker)
scene.camera.position = [0, 3, 5]
scene.camera.lookAt([0, 0, 0])
scene.open()

const ground = new Plane([0, 1, 0], [0, 0, 0])       // Y-up plane through the origin
scene.addEventListener('click', ev => {
  const ray = scene.camera.getRay(ev.clientX, ev.clientY)
  const t = ground.intersectRay(ray)
  if (t !== null) marker.position = ray.getPoint(t)  // place at the tapped floor point
})

Turn a screen tap into a camera ray: Ray

TypeScript
new Ray(origin: Vec3Like, dir: Vec3Like)   // dir need not be normalized

ray.origin           // Vec3
ray.dir              // Vec3
ray.getPoint(t)      // Vec3 — origin + t·dir

The usual source is the camera: scene.camera.getRay(screenX, screenY) builds the pick ray from the camera through a screen point (logical px — ev.clientX/clientY plug straight in). See Camera.

Note

getPoint(t)'s parameter is in units of dir's length. A camera ray's dir is unit length, so there t is a distance in world units; normalize a dir you build yourself (dir.normalize()) before treating t as meters.

Intersect a ray with the ground or a surface: Plane

An infinite geometric plane stored as a unit normal + scalar offset d (dot(normal, point)).

TypeScript
new Plane(normal: Vec3Like, point: Vec3Like)       // normal is normalized for you
Plane.fromPoints(a, b, c): Plane                   // through three points (normal from winding)
Plane.fromCoefficients(a, b, c, d): Plane          // from ax + by + cz = d

plane.normal                        // Vec3, unit length (readonly)
plane.d                             // number (readonly)

plane.intersectRay(ray): number | null    // param t ≥ 0, or null (parallel / behind the origin)
plane.intersectLine(ray): number | null   // same, but hits behind the origin allowed (t may be < 0)
plane.signedDistance(point): number       // positive on the normal's side
plane.projectPoint(point): Vec3           // closest point on the plane

Both intersection methods return the ray parameter t, not the point — feed it to ray.getPoint(t) (the tap-to-place example above). fromPoints(a, b, c)'s normal is (b − a) × (c − a): counter-clockwise seen from the side it points to.

Keep a node on a sloped surface: signedDistance, projectPoint

How far a point is above a plane (negative = below it), and the closest point on the plane — enough to stop a character sinking into a tilted platform.

TypeScript
// keep a character above a tilted platform
const surface = Plane.fromPoints(p0, p1, p2)
if (surface.signedDistance(hero.position) < 0) {
  hero.position = surface.projectPoint(hero.position)
}

Smooth random values: Noise

Native FastNoise Lite Perlin sampler, fractal (FBm) with more than one octave. Deterministic: the same coordinates always return the same value (a fixed seed), so animate by moving the sample point (e.g. use time as one axis). Values stay within about −1 … 1.

TypeScript
const noise = new Noise()

noise.get(x, y)          // sample 2D noise
noise.get3d(x, y, z)     // sample 3D noise

noise.frequency = 0.01           // get/set; default 0.01 — spatial scale (higher = busier)
noise.octaves = 4                // get/set; default 1 — fractal layers (>1 adds detail)
noise.fractalLacunarity = 2      // get/set; default 2 — frequency step between octaves
noise.fractalGain = 0.5          // get/set; default 0.5 — amplitude step between octaves

Heights from 2D noise

Sample on a grid (here one column per metre) and map −1 … 1 to a height range:

TypeScript
// a bumpy field of columns, each one's height from 2D noise
const scene = new Scene()
scene.add(Light.sun())
scene.camera.position = [0, 12, 18]
scene.camera.lookAt([0, 0, 0])
const noise = new Noise()
noise.frequency = 0.15                          // ~7 m between bumps
noise.octaves = 3                               // finer detail on top
for (let x = -10; x < 10; x++) {
  for (let z = -10; z < 10; z++) {
    const h = 1 + noise.get(x, z) * 0.8         // −1…1 → 0.2…1.8 m
    scene.add(Mesh.box({ size: [1, h, 1], position: [x, h / 2, z] }))
  }
}
scene.open()

Make a node bob and sway: Noise in an aspect

Use time as one sample axis; a second, far-away row (y = 100) gives an independent wobble.

TypeScript
// gentle idle bob + sway, as an aspect
class Hover extends Aspect<'hover'> {
  height = 1
  private noise = new Noise()
  private t = 0
  onAttach() { this.noise.frequency = 0.5 }
  update(dt: number) {
    this.t += dt
    this.node.y = this.height + this.noise.get(this.t, 0) * 0.2
    this.node.eulerAngles = [0, this.noise.get(this.t, 100) * 15, 0]
  }
}
ghost.aspect(Hover, { height: 1.5 })

Pitfalls

TypeScript
// ✗ treating intersectRay's result as the hit point
marker.position = ground.intersectRay(ray)      // it's the scalar t
// ✓
const t = ground.intersectRay(ray)
if (t !== null) marker.position = ray.getPoint(t)

// ✗ expecting a plane to block Physics.raycast — Plane is math, not a body
// ✓ give the floor a Shape (+ Physics { motion: 'static' }) to make it hittable/solid

See also

  • Physics & shapes — Physics.raycast against real bodies, RayHit
  • Camera — scene.camera.getRay(x, y), and world → screen
  • Pointer events — where clientX/clientY come from
  • Aspects — per-node behaviour like Hover
  • Vec3 & math — value semantics of the returned vectors