Skip to content

Parents, Children and Local Space

Why a turret bolted to a tank stays bolted to it without anybody writing code to keep it there. That is the payoff Section 3.1 was building toward: composition is multiplication, so a hierarchy costs one matrix product per level and nothing else.

Then the three ways it goes wrong — the product written backwards, the conversion done in the wrong direction, and one failure that belongs to hierarchies alone: a parent’s uneven scale shearing a rotated child, with nothing in the child reporting it.

A Child Is Written In Its Parent’s Coordinates

Section titled “A Child Is Written In Its Parent’s Coordinates”

That single sentence is the whole idea. The turret is placed at (0.25,0)(0.25, 0) facing forward — in the hull’s coordinates, not the world’s — and then never touched again.

Mturret,world=Mhull,world  Mturret,localM_{\text{turret,world}} = M_{\text{hull,world}} \; M_{\text{turret,local}}

Because points are columns on the right, as fixed in 3.1’s conventions, the rightmost matrix acts first. So a point starts in the deepest local space and is carried outward, one parent at a time, until it lands in the world. A three-level chain is the same rule applied twice, and the build checks that grouping the product either way gives the same answer.

Parent times child. Not child times parent. That ordering follows from the convention rather than being a separate thing to remember, which is worth knowing because it is the mistake this Section exists to prevent.

A turret on a tank, with both frames drawn
Drive the tank and watch the turret's own numbers stay put, then stretch the hull.
The code that draws it src/lib/gamedev/demos/2d/tank.scene.ts
/** A turret on a tank: both frames drawn, with a checkbox that multiplies the two the wrong way round. */
import {
  makeCanvas2D,
  arrow,
  dot as fillDot,
  label,
  line,
} from "../canvas2d.ts";
// From `controls.ts`, not `ui.ts`: the latter imports Three.js and this track must not.
import { addCheckbox, addReadout, addSlider } from "../controls.ts";
import {
  applyToDirection,
  translationOf,
} from "../../../gamedev2d/matrix2d.ts";
import { axisLengths } from "../../../gamedev2d/spaces2d.ts";
import {
  RANGE,
  fittingScale,
  hullShape,
  transforms,
  turretShape,
  turretShear,
} from "./tank-shared.ts";
import type { MountFn } from "../runner.ts";

const HULL_COLOUR = "#58a6ff";
const TURRET_COLOUR = "#7ee787";
const WRONG = "#ff7b72";
const FRAME_X = "#f0883e";
const FRAME_Y = "#d2a8ff";
const GRID = "#252b33";
const TEXT = "#9198a1";

const mount: MountFn = (el) => {
  const { ctx, width, height, clear } = makeCanvas2D(el, 330);

  const show = addReadout(el);
  const note = addReadout(el);
  const tankX = addSlider(
    el,
    "tank x",
    RANGE.tankX.min,
    RANGE.tankX.max,
    -1.5,
    draw,
    "",
    0.1,
  );
  const tankAngle = addSlider(
    el,
    "tank heading",
    RANGE.tankAngle.min,
    RANGE.tankAngle.max,
    25,
    draw,
  );
  const turretAngle = addSlider(
    el,
    "turret, relative to the hull",
    RANGE.turretAngle.min,
    RANGE.turretAngle.max,
    50,
    draw,
  );
  const hullScaleX = addSlider(
    el,
    "hull scale x",
    RANGE.hullScaleX.min,
    RANGE.hullScaleX.max,
    1,
    draw,
    "\u00D7",
    0.05,
  );
  const childFirst = addCheckbox(
    el,
    "multiply child \u00D7 parent (uncheck is correct)",
    false,
    draw,
  );

  function outline(
    points: Array<{ x: number; y: number }>,
    colour: string,
    lineWidth = 2,
  ) {
    ctx.save();
    ctx.strokeStyle = colour;
    ctx.lineWidth = lineWidth;
    ctx.beginPath();
    ctx.moveTo(points[0].x, points[0].y);
    for (const p of points.slice(1)) ctx.lineTo(p.x, p.y);
    ctx.closePath();
    ctx.stroke();
    ctx.restore();
  }

  function draw() {
    clear();
    // Derived from the slider ranges, never chosen: a rotating hull with a turret on it reaches
    // further than either shape alone.
    const unit = fittingScale(width / 2, height / 2);
    const ox = width / 2;
    const oy = height / 2;
    // World Y is up, so drawing negates it. Section 1.1's one conversion, in one place.
    const at = (p: { x: number; y: number }) => ({
      x: ox + p.x * unit,
      y: oy - p.y * unit,
    });

    line(ctx, { x: 0, y: oy }, { x: width, y: oy }, GRID, { width: 1 });
    line(ctx, { x: ox, y: 0 }, { x: ox, y: height }, GRID, { width: 1 });
    label(ctx, "world origin", ox + 6, oy + 14, TEXT);

    const params = {
      tankX: tankX(),
      tankAngleDegrees: tankAngle(),
      hullScaleX: hullScaleX(),
      turretAngleDegrees: turretAngle(),
    };
    const wrong = childFirst();
    const { hull, turret } = transforms(params, wrong);
    const shear = turretShear(params);
    const stretched = axisLengths(turret);

    /* Each frame's own axes, drawn as its two matrix columns from its own origin. Seeing them is
       what makes "the child's coordinates are the parent's" concrete rather than a sentence. */
    function drawFrame(m: typeof hull, scale: number, faded: boolean) {
      const origin = at(translationOf(m));
      const xAxis = applyToDirection(m, { x: scale, y: 0 });
      const yAxis = applyToDirection(m, { x: 0, y: scale });
      arrow(
        ctx,
        origin,
        at({
          x: translationOf(m).x + xAxis.x,
          y: translationOf(m).y + xAxis.y,
        }),
        FRAME_X,
        faded ? 1 : 1.8,
      );
      arrow(
        ctx,
        origin,
        at({
          x: translationOf(m).x + yAxis.x,
          y: translationOf(m).y + yAxis.y,
        }),
        FRAME_Y,
        faded ? 1 : 1.8,
      );
      fillDot(ctx, origin.x, origin.y, 4, TEXT);
    }

    // Where the hull is, and where the turret ended up.
    outline(hullShape(params, wrong).map(at), HULL_COLOUR);
    drawFrame(hull, 1, true);
    label(
      ctx,
      "hull frame",
      at(translationOf(hull)).x + 8,
      at(translationOf(hull)).y + 18,
      HULL_COLOUR,
    );

    outline(turretShape(params, wrong).map(at), wrong ? WRONG : TURRET_COLOUR);
    drawFrame(turret, 0.7, false);
    label(
      ctx,
      wrong ? "turret, placed wrongly" : "turret frame",
      at(translationOf(turret)).x + 8,
      at(translationOf(turret)).y - 12,
      wrong ? WRONG : TURRET_COLOUR,
    );

    show(
      `${wrong ? "turret \u00D7 hull" : "hull \u00D7 turret"} \u00B7 ` +
        `the turret's own numbers never change: mounted at (0.25, 0) facing ${turretAngle()}\u00B0 in the hull's frame \u00B7 ` +
        `in the world it sits at (${translationOf(turret).x.toFixed(2)}, ${translationOf(turret).y.toFixed(2)})`,
    );
    note(
      wrong
        ? "child \u00D7 parent reads the hull's transform as though it were written in the turret's coordinates \u2014 " +
            "set every slider to zero and the two orders agree, which is why this ships"
        : Math.abs(shear) < 1e-9
          ? `the turret's axes are square, and its own scale is ${stretched.x.toFixed(2)} by ${stretched.y.toFixed(2)} \u00B7 ` +
            "stretch the hull to see what a parent's uneven scale does to a rotated child"
          : `the hull's uneven scale has sheared the turret: its axes are ${(
              (Math.acos(Math.min(1, Math.max(-1, shear))) * 180) /
              Math.PI
            ).toFixed(
              1,
            )}\u00B0 apart instead of 90\u00B0, and its own scale reads ${stretched.x.toFixed(2)} by ${stretched.y.toFixed(2)}`,
    );
  }

  draw();

  return () => {};
};

export default mount;

Drive the tank with the first two sliders and the turret follows — but look at what is actually moving. The turret’s own numbers never change. The readout says so: mounted at (0.25,0)(0.25, 0), at whatever angle you set relative to the hull. Everything it does on screen comes from its parent.

The two little arrow pairs are each frame’s own axes, drawn straight out of its matrix columns. That is what makes “the child’s coordinates are the parent’s” something you can point at rather than a sentence to accept.

Tick the last box and the turret is composed as turret × hull instead.

It is not subtly wrong. It reads the hull’s transform as though it were written in the turret’s coordinates, which is a sentence with no meaning, and the turret lands somewhere unrelated. The values panel below pins one case: the mount belongs at (3.00,1.25)(3.00, 1.25) and the reversed product puts it at (3.25,1.00)(3.25, 1.00).

And at the identity the two orders agree exactly. Set every slider to zero and there is nothing to tell them apart. That is the shape of bug this module keeps running into — Section 2.3’s forgotten translate, correct at the origin; Section 3.1’s transform order, correct under uniform scale; and now this, correct until something moves.

Going outward is multiplication. Coming back is the inverse:

plocal=Mworld−1  pworldp_{\text{local}} = M_{\text{world}}^{-1} \; p_{\text{world}}

Which is what a mouse click needs. The pointer arrives in world coordinates and the question is where that lands on the tank, so the chain has to be undone.

Out to the world, back again, and what shear does on the way
The code src/lib/gamedev/demos/2d/localworld.ts
/** One point carried out to the world and back, and what a parent's uneven scale does to a child. */
import {
  multiply,
  sameMatrix,
  translationOf,
} from "../../../gamedev2d/matrix2d.ts";
import {
  axisLengths,
  isSquare,
  localUnderNewParent,
  matrixOf,
  placed,
  pointToLocal,
  pointToWorld,
  shearOf,
  worldOf,
} from "../../../gamedev2d/spaces2d.ts";
import type { Demo } from "../runner.ts";

const quarter = Math.PI / 2;
const at = (p: { x: number; y: number } | null) =>
  p === null ? "null" : `(${p.x.toFixed(2)}, ${p.y.toFixed(2)})`;
const degreesApart = (m: Parameters<typeof shearOf>[0]) =>
  ((Math.acos(Math.min(1, Math.max(-1, shearOf(m)))) * 180) / Math.PI).toFixed(
    1,
  );

/** A tank at (3, 1) turned a quarter turn, with a turret mounted forward of its centre. */
const HULL = placed({ x: 3, y: 1 }, quarter);
const TURRET = placed({ x: 0.25, y: 0 }, 0);
const TURRET_WORLD = worldOf([HULL, TURRET]);

/** The tip of the barrel, in the turret's own coordinates. It never changes. */
const BARREL_TIP = { x: 1.15, y: 0 };

const demo: Demo = (log) => {
  log(
    "the barrel tip, in the turret's own coordinates",
    at(BARREL_TIP),
    "a constant: the only place the barrel's length is written down",
  );
  log(
    "the same point in the world, hull at (3, 1) turned 90 degrees",
    at(pointToWorld(TURRET_WORLD, BARREL_TIP)),
    "the hull turned, so the barrel now points along world +y",
  );
  log(
    "converted back into the turret's coordinates",
    at(pointToLocal(TURRET_WORLD, pointToWorld(TURRET_WORLD, BARREL_TIP))),
    "the round trip, which is what makes a mouse click usable",
  );

  // Order is not a preference. Both products are valid matrices; only one is the hierarchy.
  log(
    "where the mount lands, as hull x turret against turret x hull",
    `${at(translationOf(TURRET_WORLD))} against ${at(translationOf(worldOf([TURRET, HULL])))}`,
    "the wrong order is not slightly off, it is a different place entirely",
  );

  // A parent's uneven scale reaches a rotated child as shear, not as scale.
  const sheared = worldOf([
    placed({ x: 0, y: 0 }, 0, { x: 2, y: 1 }),
    placed({ x: 0, y: 0 }, Math.PI / 4),
  ]);
  log(
    "a child turned 45 degrees under a parent scaled 2 by 1",
    `its axes are ${degreesApart(sheared)} degrees apart, of lengths ${at(axisLengths(sheared))}`,
    "a square went in and a parallelogram came out: the child was sheared, not scaled",
  );
  log(
    "isSquare on that frame",
    isSquare(sheared),
    "and nothing the child owns has changed, so nothing it owns can report this",
  );

  // Reparenting: hold the world transform still and solve for the new local one.
  const newParent = matrixOf(placed({ x: -2, y: 4 }, -quarter));
  const rehomed = localUnderNewParent(newParent, TURRET_WORLD);
  log(
    "re-home the turret under a parent at (-2, 4), keeping it where it is",
    rehomed !== null && sameMatrix(multiply(newParent, rehomed), TURRET_WORLD),
    "inverse of the new parent times the world transform, and nothing moves on screen",
  );
};

export default demo;
the barrel tip, in the turret's own coordinates → (1.15, 0.00) // a constant: the only place the barrel's length is written down
the same point in the world, hull at (3, 1) turned 90 degrees → (3.00, 2.40) // the hull turned, so the barrel now points along world +y
converted back into the turret's coordinates → (1.15, 0.00) // the round trip, which is what makes a mouse click usable
where the mount lands, as hull x turret against turret x hull → (3.00, 1.25) against (3.25, 1.00) // the wrong order is not slightly off, it is a different place entirely
a child turned 45 degrees under a parent scaled 2 by 1 → its axes are 126.9 degrees apart, of lengths (1.58, 1.58) // a square went in and a parallelogram came out: the child was sheared, not scaled
isSquare on that frame → false // and nothing the child owns has changed, so nothing it owns can report this
re-home the turret under a parent at (-2, 4), keeping it where it is → true // inverse of the new parent times the world transform, and nothing moves on screen

The first three rows are the round trip: the barrel tip is (1.15,0)(1.15, 0) in the turret’s own coordinates, which with the hull at (3,1)(3, 1) turned a quarter turn puts it at (3.00,2.40)(3.00, 2.40) in the world, and back to exactly (1.15,0)(1.15, 0) again. That round trip is swept over a grid of hull positions and angles at build time, because an inverse that is slightly wrong still returns a perfectly plausible point.

Give the hull an uneven scale and watch the turret.

A parent scaled unevenly does not scale a rotated child unevenly. It shears it. The child’s axes are no longer lined up with the parent’s, so each one picks up a different share of the stretch, and a square child comes out a parallelogram.

Here is a 2×12 \times 1 parent against a child at various angles, measured rather than described:

Child’s rotationAngle between its axesIts axis lengthsStill square?
0°0°90°90°2.000, 1.000yes
15°15°110.6°110.6°1.949, 1.096no
30°30°123.0°123.0°1.803, 1.323no
45°45°126.9°126.9°1.581, 1.581no
60°60°123.0°123.0°1.323, 1.803no
90°90°90°90°1.000, 2.000yes

Three things worth reading off that table. The axis-aligned cases survive — at 0°0° and 90°90° the child is scaled, not sheared, because its axes still coincide with its parent’s and only swap roles. 45°45° is the worst case, and there the two axis lengths come out equal: the frame is a rhombus, which is as far from a rectangle as it gets. And none of those lengths are numbers anybody typed. The child’s scale is (1,1)(1, 1) throughout.

That last point is why this needs measuring instead of describing. Nothing in the child’s own placement changed, so nothing in the child can report the problem — shearOf has to ask the composed frame whether its axes are still perpendicular, using Section 1.4’s dot product. The build sweeps every 5°5° from 5°5° to 85°85° and asserts each one is sheared, plus that a uniform parent scale never shears anything at any angle.

Picking an item up, dropping a passenger out of a vehicle, attaching a projectile to whatever it hit — all the same operation. The thing must not move on screen, so hold its world transform fixed and solve for the local one:

Mlocal=MnewParent−1  MworldM_{\text{local}} = M_{\text{newParent}}^{-1} \; M_{\text{world}}

Undo the new parent, then apply the world transform. That shape is the general rule (AB)−1=B−1A−1(AB)^{-1} = B^{-1}A^{-1} read in a useful direction. The last row of the panel above confirms it, and the build sweeps it over a range of new parents, checking both that the recomposed matrix equals the original and that a specific point on the child does not budge.

source Hierarchies, both conversions, and the shear detector src/lib/gamedev2d/spaces2d.ts 164 lines
/**
 * Parents, children, and the two directions you convert between them.
 *
 * A hierarchy is one idea: **a child's transform is written relative to its parent**, so the child's
 * transform in world terms is the parent's world transform times the child's local one. A turret is
 * placed on the tank once, at (0, 0.4) facing forward, and it stays there whatever the tank does. Move
 * the tank and the turret comes along, for free, because the multiplication is doing the work.
 *
 * Everything here is Section 3.1's matrix with names attached. The parts worth being careful about are
 * the order of the product, the direction of the conversion, and one thing a hierarchy can do that a
 * single transform cannot: shear a child that its parent scaled unevenly.
 */
import {
  apply,
  applyToDirection,
  compose,
  inverse,
  multiply,
  rotation,
  scaling,
  translation,
  type Mat3,
} from "./matrix2d.ts";
import { cross } from "./cross2d.ts";
import { dot } from "./dot2d.ts";
import { normalize } from "./length2d.ts";
import type { Point, Vector } from "./vectors2d.ts";

/** Where something sits, which way it faces, and how big it is - relative to its parent. */
export type Placement = {
  position: Point;
  /** Radians, counter-clockwise, as everywhere else in this module. */
  rotation: number;
  scale: Vector;
};

/** The identity placement: at the origin, unrotated, unscaled. */
export function placed(
  position: Point = { x: 0, y: 0 },
  rotationRadians = 0,
  scale: Vector = { x: 1, y: 1 },
): Placement {
  return { position, rotation: rotationRadians, scale };
}

/**
 * One placement as a matrix, in the $T R S$ order Section 3.1 settled on.
 *
 * Scale first, then rotate, then translate. Every engine's local transform composes this way, which
 * is why that Section spent a panel on the six orderings.
 */
export function matrixOf(p: Placement): Mat3 {
  return compose(
    translation(p.position.x, p.position.y),
    rotation(p.rotation),
    scaling(p.scale.x, p.scale.y),
  );
}

/**
 * A chain of placements from the root down to the thing itself, as one world transform.
 *
 * $$M_{\text{world}} = M_{\text{root}} \; M_{\text{child}} \; M_{\text{grandchild}} \cdots$$
 *
 * **Root first.** Because points are columns on the right, the rightmost matrix is applied first, and
 * the rightmost matrix is the deepest child - the one whose coordinates the point is written in. So a
 * point starts in the deepest local space and gets carried outward, one parent at a time, until it is
 * in world space. Reverse the list and you get a transform that is not wrong in any single term and
 * is wrong in its entirety.
 */
export function worldOf(chain: readonly Placement[]): Mat3 {
  return compose(...chain.map(matrixOf));
}

/** A place, from a local space out to the world. */
export function pointToWorld(world: Mat3, local: Point): Point {
  return apply(world, local);
}

/**
 * A place, from the world back into a local space. The inverse direction, and the one that needs one.
 *
 * This is what a mouse click needs: the pointer arrives in world coordinates and the question is where
 * that is on the tank. Returns `null` when the transform cannot be inverted, which for a hierarchy
 * means some ancestor has a zero scale - the child has been flattened to nothing and no position on it
 * is recoverable.
 */
export function pointToLocal(world: Mat3, worldPoint: Point): Point | null {
  const back = inverse(world);
  return back === null ? null : apply(back, worldPoint);
}

/** A direction out to the world: rotated and scaled by the chain, never translated. Section 3.1's $w = 0$. */
export function directionToWorld(world: Mat3, local: Vector): Vector {
  return applyToDirection(world, local);
}

/** And back again. `null` on a collapsed chain, for the same reason as `pointToLocal`. */
export function directionToLocal(
  world: Mat3,
  worldVector: Vector,
): Vector | null {
  const back = inverse(world);
  return back === null ? null : applyToDirection(back, worldVector);
}

/**
 * The local transform a child needs to keep its current world transform under a **new** parent.
 *
 * $$M_{\text{local}} = M_{\text{newParent}}^{-1} \; M_{\text{world}}$$
 *
 * This is reparenting, and it is the operation behind "pick up the item without it jumping": the thing
 * must not move on screen, so its world transform is held fixed and its local one is solved for.
 * Note the shape - undo the new parent, then apply the world transform - which is the general rule
 * that $(AB)^{-1} = B^{-1}A^{-1}$ read in a useful direction.
 */
export function localUnderNewParent(
  newParentWorld: Mat3,
  desiredWorld: Mat3,
): Mat3 | null {
  const back = inverse(newParentWorld);
  return back === null ? null : multiply(back, desiredWorld);
}

/**
 * How far from square a transform's own axes have become. Zero for any rigid or uniformly scaled frame.
 *
 * This is the thing a hierarchy can do that a single placement cannot. A parent scaled unevenly does
 * not scale its rotated child unevenly - it **shears** it, because the child's axes are no longer
 * along the parent's, so each picks up a different amount of stretch. A square child comes out a
 * parallelogram, and nothing in the child's own numbers changed.
 *
 * Measured as the cosine of the angle between the two transformed axes, using Section 1.4's dot
 * product. It is the honest test, because "looks skewed" is not something a build can check.
 */
export function shearOf(m: Mat3): number {
  const xAxis = normalize({ x: m[0], y: m[3] });
  const yAxis = normalize({ x: m[1], y: m[4] });
  if (xAxis === null || yAxis === null) return 0;
  return dot(xAxis, yAxis);
}

/** Are the transform's axes still perpendicular? A shear-free frame, whatever its rotation or scale. */
export function isSquare(m: Mat3, tolerance = 1e-9): boolean {
  return Math.abs(shearOf(m)) < tolerance;
}

/**
 * How much the frame stretches each of its own axes, which is what "scale" means once shear is possible.
 *
 * The lengths of the two transformed axes. For a rigid transform both are 1; under a parent's uneven
 * scale they differ, and they are not the numbers the child was given.
 */
export function axisLengths(m: Mat3): Vector {
  return {
    x: Math.hypot(m[0], m[3]),
    y: Math.hypot(m[1], m[4]),
  };
}

/** Did the chain mirror the child? The determinant's sign, from Section 3.1, read through the axes. */
export function isMirrored(m: Mat3): boolean {
  return cross({ x: m[0], y: m[3] }, { x: m[1], y: m[4] }) < 0;
}
  • Anything mounted on anything: turrets, wheels, held items, a health bar over a head.
  • Mouse picking, which is a world point converted into a local space. Section 3.3 does the first half of that conversion, from screen to world.
  • Cameras, which are this idea inverted: the whole world becomes a child of the camera. Section 3.3.
  • Sprite flipping, where a negative parent scale mirrors every child — Section 3.1’s determinant.
  • Collision shapes, which must be transformed into a shared space before they can be compared. Part 5 assumes you can do this.