Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -325,39 +325,43 @@ jobs:
# waited on it; in four, each builds about 45. The first shard of a
# tier keeps the tier's name, runs the steps that run once, and
# writes the determinism trail (`trail`).
- os: ubuntu-latest
# Pinned to 24.04, not ubuntu-latest: latest became 26.04 (gcc 15) on
# 2026-10-31 and gcc 15 warns -Wstringop-overflow (a memset whose
# size may come from a negative int) in the motion-heavy -O2 builds,
# which ci.sh reads as a failure (#901). Back to latest once fixed.
- os: ubuntu-24.04
name: linux
tier: suites
shard: 0/4
trail: true
gpu: 1
packages: vulkan-validationlayers
- os: ubuntu-latest
- os: ubuntu-24.04
name: linux-2
tier: suites
shard: 1/4
gpu: 1
packages: vulkan-validationlayers
- os: ubuntu-latest
- os: ubuntu-24.04
name: linux-3
tier: suites
shard: 2/4
gpu: 1
packages: vulkan-validationlayers
- os: ubuntu-latest
- os: ubuntu-24.04
name: linux-4
tier: suites
shard: 3/4
gpu: 1
packages: vulkan-validationlayers
# The examples on one, the editor on the other.
- os: ubuntu-latest
- os: ubuntu-24.04
name: linux-apps
tier: apps
shard: 0/2
gpu: 1
packages: libgtk-4-dev libepoxy-dev
- os: ubuntu-latest
- os: ubuntu-24.04
name: linux-editor
tier: apps
shard: 1/2
Expand Down
18 changes: 18 additions & 0 deletions changes/2026-10-31-text-and-closed-paths.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
### World text: upright, sized in pixels, outlined and shadowed; closed camera paths (#876, #889 answers)

Nic's answers to the #876 and #889 questions, the four items for one PR (876-4, 876-5, 876-8, 889-4):

- `hud.world_text_upright`: a label turned to the camera about the world's up only (876-4).
- `hud.world_text_pixels(h, on)`: sizes in screen pixels, the scale found at each draw from that draw's camera and
the shown height of its frame, so it holds per view and under the render scale (876-5).
- `hud.world_text_outline` and `hud.world_text_shadow`: runs of their own under the text, the outline by the HUD's
lowered-threshold rule (876-8). The edge reaches the pixel shader as a flat int in 65536ths (0.5 exactly as
32768), so a plain run's frames are unchanged byte for byte. Depth, pixels, outline and shadow are state until
the frame's clear, as `world_text_depth` was (876-7).
- `campath.set_closed(p, seconds)`: a last-to-first segment, the Catmull-Rom tangents wrapping (889-4).
- Tests: `test_worldtext` (54, CPU: facings, the pixel scale for perspective, orthographic and a half-height frame,
the outline edge against the HUD's rule, the shadow's offset, the clear), `test_world_text` (65, both backends:
the new frames held to each other, and the old ones byte for byte against main), `test_gpu_lettering` (35, the
edge varying and a lowered edge on the CPU), `test_campath` (the closed path's values worked by hand, C1 round
the loop). Negative controls: the shader with the edge ignored fails the outline checks; the tangents unwrapped
fail four closed-path checks.
6 changes: 3 additions & 3 deletions ci.sh
Original file line number Diff line number Diff line change
Expand Up @@ -672,7 +672,7 @@ for suite in $SUITES; do
fi
if grep -qE "warning|^error" "$BUILD_DIR/$name.log"; then
fail "$name (build warnings or errors)"
grep -E "warning|^error" "$BUILD_DIR/$name.log" | sed 's/^/ /' | head -10
grep -E "warning|^error|In function|inlined from" "$BUILD_DIR/$name.log" | sed 's/^/ /' | head -30
continue
fi
needs_window=0
Expand Down Expand Up @@ -805,7 +805,7 @@ for example in examples/*.ae; do
fi
if grep -qE "warning|^error" "$BUILD_DIR/$name.log"; then
fail "$name (build warnings or errors)"
grep -E "warning|^error" "$BUILD_DIR/$name.log" | sed 's/^/ /' | head -10
grep -E "warning|^error|In function|inlined from" "$BUILD_DIR/$name.log" | sed 's/^/ /' | head -30
continue
fi
if ! have_display; then
Expand Down Expand Up @@ -1134,7 +1134,7 @@ else
# not, so an unused variable in the largest Aether source in the repo
# went through ci without a word.
fail "ae3d_editor (build warnings or errors)"
grep -E "warning|^error" /tmp/ae3d_build.log | sed 's/^/ /' | head -10
grep -E "warning|^error|In function|inlined from" /tmp/ae3d_build.log | sed 's/^/ /' | head -30
else
sed -n 's/^built: / /p' /tmp/ae3d_build.log
# The roundtrip scene is this one saved and loaded again before the
Expand Down
38 changes: 33 additions & 5 deletions docs/rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -1083,15 +1083,37 @@ instead of a place on the screen (#871):
```
hud.world_text(h, x, y, z, 0.4, "Grunt", hud.rgba(1.0, 1.0, 1.0, 1.0)) // faces the camera
hud.world_text_fixed(h, x, y, z, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.5, "EXIT", c) // lies along right and up
hud.world_text_upright(h, x, y, z, 0.4, "Oak", c) // turns about the world's up only
hud.world_text_depth(h, false) // the runs after it are over everything
hud.world_text_pixels(h, true) // their sizes in screen pixels
hud.world_text_outline(h, hud.rgba(0.0, 0.0, 0.0, 1.0), 2.0) // outlined, 2 of the size's units wide
hud.world_text_shadow(h, hud.rgba(0.0, 0.0, 0.0, 0.6), 2.0) // shadowed, 2 down and right
```

The size is the em in the world's units, so a label shrinks with distance as
the thing it names does; the text is centred on the point both ways. A
`world_text` run faces the camera it is drawn from (its axes are the camera's,
so it is parallel to the picture) -- the window's, or a view's: a view draws
the world text from its own camera. A `world_text_fixed` run lies in the plane
its two axes make, drawn from both sides and mirrored from behind. Colours are
the world text from its own camera. A `world_text_upright` run turns to the
camera about the world's up (+y) only, its lines level and its tops up however
high or low the camera is: a sign on a post. A `world_text_fixed` run lies in
the plane its two axes make, drawn from both sides and mirrored from behind.

After `world_text_pixels(h, true)` the size is the em in pixels on the screen,
so a name tag is as readable far away as near. Its scale is found at each
draw, from that draw's camera and the height of the frame as it is shown (the
window's, or a view's; the render scale does not change it): twice the point's
clip w over the projection's vertical scale and the height is the world a
pixel spans there. It holds under an orthographic camera too.

`world_text_outline(h, colour, width)` draws a run of the same glyphs under the
text in `colour`, its edge lowered in the field by `width` (the HUD's
`text_outlined` rule, and its limit: about 0.08 of the em); `world_text_shadow(h,
colour, offset)` a run moved `offset` right and down along the text's own axes.
Both are in the size's units, so a share of the em at any size, and each is a
run (and a draw) of its own, before the text. Like `world_text_depth`, the
pixels, outline and shadow switches hold for the runs after them until the
frame's `hud.clear`. Colours are
display values, as the HUD's, turned to the scene's light the way the clear
colour is. `hud.clear` forgets the runs after each frame like the rectangles;
512 runs a frame at most, the rest dropped and said once.
Expand All @@ -1112,7 +1134,9 @@ the bloom and the tone curve. Three things follow from that:
(text is not picked: the click lands on what is behind).
- The atlas is read at one level, as the HUD reads it: far away and small a
label is blurred rather than sharp (the field's width grows with the
minification) and can shimmer in motion.
minification) and can shimmer in motion. Mips would sharpen it but make the
two APIs' frames differ by the driver's mip filter, so it stays one level;
a label sized in pixels does not shrink, and does not shimmer.
- With nothing asked for nothing runs: the frame is the frame it was.

What a frame pays (`benchmarks/bench_world_text`, 1280x720, 24 spheres, an
Expand Down Expand Up @@ -1469,8 +1493,12 @@ say. Between two target keys the target is interpolated the same way and
looked at, so a camera circling a point keeps it in the middle of the
frame; any other segment slerps the two look directions at a constant angular
speed (the camera has no roll, so a direction is its orientation). A path
is open: looping jumps from the last key to the first, so end a loop on its
first key to make it seamless. See the module's header for the reasoning.
is open unless `set_closed(p, seconds)` closes it: then a segment that long
goes from the last key back to the first, the Catmull-Rom tangents wrapping
round it (the first key's neighbour before it is the last, a period earlier),
so a closed path that loops goes round with no jump in place or speed. Open,
looping jumps from the last key to the first. See the module's header for the
reasoning.

**A view follows a path** by being drawn from a camera a path is attached to:
`campath.attach(e, p, view_cam)` with the view's own camera
Expand Down
10 changes: 7 additions & 3 deletions docs/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,9 +272,13 @@ program sets one (`engine_set_ui_scale`, or `AE3D_UI_SCALE`).
gets a line of its own.
- **Text in the world.** `hud.world_text(o, x, y, z, size, text, colour)` is
a label at a point in the scene, `size` tall to the em in the world's units,
facing the camera; `world_text_fixed` lies in a plane; `world_text_depth`
decides whether what is nearer hides it. It uses the same atlas and is drawn
into the scene, not over it: docs/rendering.md, "Text in the world".
facing the camera; `world_text_upright` turns about the world's up only;
`world_text_fixed` lies in a plane. `world_text_depth` decides whether what
is nearer hides it, `world_text_pixels` sizes it in screen pixels instead,
and `world_text_outline` and `world_text_shadow` outline and shadow it as
`text_outlined` and `text_shadowed` do; all four are state until the frame's
clear. It uses the same atlas and is drawn into the scene, not over it:
docs/rendering.md, "Text in the world".
- **Clipped panels.** `hud.push_clip(o, x, y, w, h)` and `hud.pop_clip(o)`
bound what is drawn to a panel, nested panels to where they meet. Each
quad is cut to the clip as it is added, and the glyph or image area it
Expand Down
83 changes: 66 additions & 17 deletions src/ae3d/campath/module.ae
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,15 @@
// to the last's at `rate` times real time (negative plays backwards). At the
// end a path stops there, or, looping, goes round to the start; seek moves
// the playhead (held to the keys' range, or wrapped into it when looping).
// A path is open: looping jumps from the last key to the first.
//
// A path is open unless closed: looping an open path jumps from the last key
// to the first. set_closed(p, seconds) closes it, with a segment of that many
// seconds from the last key back to the first, interpolated as the others
// are: the Catmull-Rom tangents wrap (the first key's neighbour before it is
// the last, a period earlier, and the last's after it the first, a period
// later), so a closed path that loops goes round with no jump and no kink.
// Its end is the last key's time and the closing segment's; there it is
// back at the first key.
import std.heap
import std.list
import ae3d.core
Expand All @@ -61,8 +69,8 @@ exports (
path_new, path_free, path_clear, path_count, path_add_target, path_add_look, path_add_camera,
path_start, path_end, path_duration, path_key_time,
sample, apply_pose, apply,
play, pause, stop, seek, set_loop, set_rate, advance,
path_time, path_playing, path_finished, path_looping, path_rate,
play, pause, stop, seek, set_loop, set_rate, set_closed, advance,
path_time, path_playing, path_finished, path_looping, path_rate, path_closing,
attach, detach, slerp
)

Expand Down Expand Up @@ -92,6 +100,7 @@ struct CameraPath {
is_playing: bool
is_looping: bool
is_finished: bool
closing: float // the last-to-first segment's seconds; 0 open
dirty: bool // the playhead moved since the camera was put on it
engine: ptr
behaviour: ptr
Expand Down Expand Up @@ -138,8 +147,20 @@ path_start(p: *CameraPath) -> float {
path_end(p: *CameraPath) -> float {
n = list.size(p.keys)
if n == 0 { return 0.0 }
if closed(p) { return key_at(p, n - 1).time + p.closing }
return key_at(p, n - 1).time
}

// Whether the path goes back from its last key to its first: closed, with
// two keys or more.
closed(p: *CameraPath) -> bool { return p.closing > 0.0 && list.size(p.keys) >= 2 }

// The key after the i-th: the next, or the first after the last of a closed
// path.
next_key(p: *CameraPath, i: int) -> int {
if i + 1 >= list.size(p.keys) { return 0 }
return i + 1
}
path_duration(p: *CameraPath) -> float { return path_end(p) - path_start(p) }

// The unit vector a yaw and a pitch (degrees, the camera's) look along.
Expand Down Expand Up @@ -214,22 +235,33 @@ value_of(k: *Key, which: int) -> Vec3 {
}

// The velocity a key is passed at: the line through its neighbours in time
// (Catmull-Rom), the one segment's at the ends. A neighbour whose aim is not
// a point (a look key, with `which` 1) stands in for the key itself.
// (Catmull-Rom), the one segment's at the ends of an open path; a closed
// path's ends are each other's neighbours, a period apart. A neighbour whose
// aim is not a point (a look key, with `which` 1) stands in for the key
// itself.
tangent(p: *CameraPath, i: int, which: int) -> Vec3 {
n = list.size(p.keys)
if n < 2 { return core.v3_zero() }
k = key_at(p, i)
period = path_end(p) - path_start(p)
lo = i - 1
hi = i + 1
if lo < 0 { lo = 0 }
if hi > n - 1 { hi = n - 1 }
shift_lo = 0.0
shift_hi = 0.0
if lo < 0 {
lo = 0
if closed(p) { lo = n - 1 ; shift_lo = 0.0 - period }
}
if hi > n - 1 {
hi = n - 1
if closed(p) { hi = 0 ; shift_hi = period }
}
a = key_at(p, lo)
b = key_at(p, hi)
va = value_of(a, which)
vb = value_of(b, which)
ta = a.time
tb = b.time
ta = a.time + shift_lo
tb = b.time + shift_hi
if which == 1 {
if a.kind != KEY_TARGET { va = k.aim ; ta = k.time }
if b.kind != KEY_TARGET { vb = k.aim ; tb = k.time }
Expand All @@ -255,9 +287,10 @@ hermite(a: Vec3, ma: Vec3, b: Vec3, mb: Vec3, h: float, u: float) -> Vec3 {
}

spline(p: *CameraPath, i: int, which: int, h: float, u: float) -> Vec3 {
j = next_key(p, i)
a = value_of(key_at(p, i), which)
b = value_of(key_at(p, i + 1), which)
return hermite(a, tangent(p, i, which), b, tangent(p, i + 1, which), h, u)
b = value_of(key_at(p, j), which)
return hermite(a, tangent(p, i, which), b, tangent(p, j, which), h, u)
}

// The shortest arc between two unit vectors, `u` of the way: a constant
Expand Down Expand Up @@ -298,7 +331,8 @@ all_have_fov(p: *CameraPath) -> bool {
}

// The camera at `t`: before the first key it is the first, after the last the
// last. No keys, a pose at the origin looking down -z.
// last (the first again for a closed path, after its closing segment). No
// keys, a pose at the origin looking down -z.
sample(p: *CameraPath, t: float) -> Pose {
n = list.size(p.keys)
if n == 0 { return Pose { position: core.v3_zero(), front: core.vec3(0.0, 0.0, 0.0 - 1.0), fov: 0.0 } }
Expand All @@ -310,16 +344,21 @@ sample(p: *CameraPath, t: float) -> Pose {
if fov_set { f0 = first.fov }
return Pose { position: first.position, front: key_front(first), fov: f0 }
}
if t >= last.time {
if t >= path_end(p) {
home = last
if closed(p) { home = first }
f1 = 0.0
if fov_set { f1 = last.fov }
return Pose { position: last.position, front: key_front(last), fov: f1 }
if fov_set { f1 = home.fov }
return Pose { position: home.position, front: key_front(home), fov: f1 }
}
// The segment t is in: an open path's ends before the last key, so its
// last segment ends there; a closed path's goes back to the first.
i = 0
while i < n - 2 && key_at(p, i + 1).time <= t { i = i + 1 }
while i < n - 1 && key_at(p, i + 1).time <= t { i = i + 1 }
a = key_at(p, i)
b = key_at(p, i + 1)
b = key_at(p, next_key(p, i))
h = b.time - a.time
if i == n - 1 { h = p.closing }
u = (t - a.time) / h

position = spline(p, i, 0, h, u)
Expand Down Expand Up @@ -369,6 +408,16 @@ path_rate(p: *CameraPath) -> float { return p.rate }
set_loop(p: *CameraPath, on: bool) { p.is_looping = on }
set_rate(p: *CameraPath, r: float) { p.rate = r }

// Closed with a segment `seconds` long from the last key back to the first,
// or open again with 0 (or less). The playhead is held to the new range.
set_closed(p: *CameraPath, seconds: float) {
p.closing = 0.0
if seconds > 0.0 { p.closing = seconds }
p.head = hold(p, p.head)
p.dirty = true
}
path_closing(p: *CameraPath) -> float { return p.closing }

// Play from the playhead; a path that has finished plays again from the
// end it stopped at, back the way it came if the rate is reversed.
play(p: *CameraPath) {
Expand Down
4 changes: 3 additions & 1 deletion src/ae3d/gl/module.ae
Original file line number Diff line number Diff line change
Expand Up @@ -6004,7 +6004,9 @@ draw_world_text(r: *GLRenderer, view_projection: *Mat4, cam: *Camera) {
if hud.atlas(o) == null { return }
view = core.camera_view(cam)
instances = calloc(n, worldtext.INSTANCE_BYTES)
worldtext.instances(w, view.m0, view.m4, view.m8, view.m1, view.m5, view.m9, instances)
// The frame's height as it is shown (a view's, when drawing one), not
// the render scale's: a run sized in pixels is sized in those.
worldtext.instances(w, &view, &cam.projection, r.viewport_height as float, instances)
vs = r.text_vertex_shader as *GpuShader
ps = r.text_shader as *GpuShader
_vp = gpu.set_mat4(vs, "view_projection", view_projection as ptr)
Expand Down
Loading
Loading