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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ Graphics
- **2D** — `Light2d` as a first-class `Renderable` (multiple dynamic lights, radial-gradient falloff, illumination-only mode, procedural rendering via `drawLight`), plus optional per-pixel normal-map shading on sprites for 3D-looking dynamic lights
- **3D** — `Light3d` directional, point, spot and ambient lights, added to the world like `Light2d` (half-Lambert diffuse + ambient fill, quadratic falloff and cone angles on the punctual types, runtime-manipulable for day/night), auto-loaded from a glTF scene's authored suns and lamps
- up to **32 simultaneous lights** on either path, with light data travelling in a uniform buffer — a static light rig costs zero GPU state changes per frame
- Ground ("blob") shadows for 3D objects — **on by default** — so characters and props read as standing on the floor instead of hovering. An ellipse matching the caster's own footprint and rotation, shrinking and fading with height above the ground; one extra draw per object, and **one for an entire `InstancedMesh` scatter regardless of instance count**. Controllable per object, per glTF scene (`level.load(name, { castGroundShadow })`) or application-wide. Deliberately not shadow mapping: it answers "where is this standing", which is what a 2.5D scene needs
- Built-in shader effects (Flash, Outline, Glow, Dissolve, CRT, Hologram, etc.) with multi-pass chaining via `addPostEffect()`, plus custom shader support on both GPU backends: `ShaderEffect` for per-sprite fragment effects (GLSL and/or WGSL bodies) and complete custom mesh shader programs via `mesh.shader` (a dual-language `GLShader`: GLSL pair and/or WGSL module)
- Trail renderable for fading, tapering ribbons behind moving objects (speed lines, sword slashes, magic trails)
- System & Bitmap Text with built-in typewriter effect
Expand Down
18 changes: 16 additions & 2 deletions packages/examples/src/examples/billboard/ExampleBillboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,12 @@ function bakeGrid() {
c.height = 64;
const ctx = c.getContext("2d");
if (ctx) {
ctx.fillStyle = "#161a24";
// light enough for a ground shadow to have something to darken: a blob
// is a subtraction, and on the near-black this used to be there was
// nothing to subtract from
ctx.fillStyle = "#4a5372";
ctx.fillRect(0, 0, 64, 64);
ctx.strokeStyle = "#2c3550";
ctx.strokeStyle = "#6f7ba3";
ctx.lineWidth = 2;
ctx.strokeRect(0, 0, 64, 64);
}
Expand Down Expand Up @@ -212,6 +215,11 @@ const createGame = async () => {
z: 0,
billboard: mode,
anchorPoint: "bottom", // feet at pos — character only
castGroundShadow: true,
// the floor plane, which the game knows and the engine does
// not: left unset the blob falls back to the sprite's own
// base, and a billboard's base moves with the camera
shadowGroundY: GY,
});
guy.addAnimation("walk", WALK_FRAMES, 90);
guy.setCurrentAnimation("walk");
Expand All @@ -228,6 +236,12 @@ const createGame = async () => {
z: 0,
billboard: "spherical",
// no anchorPoint — the default center is correct here
// a floating label has no ground beneath it to make contact
// with, so it opts out of the application-wide default. Its
// blob happens to be edge-on and invisible at this camera
// height — saying so explicitly keeps that from depending on
// where the camera happens to sit
castGroundShadow: false,
});
app.world.addChild(tag);
}
Expand Down
16 changes: 15 additions & 1 deletion packages/examples/src/examples/forest/ExampleForest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ const base = `${import.meta.env.BASE_URL}assets/gltf/`;
// pixels per glTF unit
const SCALE = 26;

// The forest floor: the glb's ground plane sits at y = 0, and the trees stand
// on it, so every blob in the scene lands here.
const GROUND_Y = 0;

/** A dusk sky, drawn screen-fixed behind the scene. */
function bakeSky() {
const c = document.createElement("canvas");
Expand Down Expand Up @@ -234,7 +238,17 @@ const createGame = async () => {
state.change(state.DEFAULT, true);
// one call — the instanced node becomes an InstancedMesh, the
// ground stays an ordinary Mesh, and the authored sun lights both
level.load("forest", { scale: SCALE, onLoaded: setupScene });
// Instanced ground shadows (#1515) come from the load option: ONE
// extra draw for the whole scatter, however many trees are visible,
// read from the same instance buffer the trees draw from. The
// scene's ground plane is skipped automatically — it has no height
// to cast, and shadowing it with itself would smear the floor.
level.load("forest", {
scale: SCALE,
castGroundShadow: true,
shadowGroundY: GROUND_Y,
onLoaded: setupScene,
});
},
);

Expand Down
52 changes: 51 additions & 1 deletion packages/examples/src/examples/gltf/ExampleGltf.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
input,
level,
loader,
Mesh,
type Pointer,
plugin,
Renderable,
Expand Down Expand Up @@ -112,6 +113,45 @@ const createGame = async () => {
if (!scene) {
return;
}
// Give each prop the surface it actually stands over (#1515). The scene
// has platforms at three heights, so there is no single floor to pass as
// `shadowGroundY` — and left unset, a blob sits at the object's OWN
// base, which for a hovering pickup is mid-air directly beneath it,
// hidden by the pickup. Render space is Y-DOWN, so "below" is a GREATER
// y: the right surface is the smallest platform top still greater than
// the prop's base. Setting it is also what switches on the shrink-and-
// fade with height, which is what makes a floating coin read as floating.
// `instanceof`, not a cast: `world.children` is typed as the BASE class,
// so `castGroundShadow` / `getBounds3d()` — which live on `Mesh` — are
// not visible on it. Narrowing proves they are there instead of
// silencing the compiler, which keeps every member access below checked.
const meshes = app.world.children.filter(
(c): c is Mesh => c instanceof Mesh,
);
const platformTops = meshes
.filter((m) => /^block/.test(m.name ?? ""))
.map((m) => m.getBounds3d().top);
for (const prop of meshes) {
if (/^block/.test(prop.name ?? "")) {
continue;
}
const base = prop.getBounds3d().bottom;
let ground: number | undefined;
for (const top of platformTops) {
// A tolerance, not a strict compare: a prop resting on a platform
// has a base that IS that platform's top, but only to within
// float error — and an exact test skips the very surface it
// stands on, dropping its shadow a tier down where the upper
// platform hides it. Half a pixel at this scene's scale.
if (top >= base - 0.5 && (ground === undefined || top < ground)) {
ground = top;
}
}
if (ground !== undefined) {
prop.shadowGroundY = ground;
}
}

const { min, max } = scene.bounds;
// render space: glTF (x,y,z) → (x, -y, -z) * SCALE (rightHanded rotation)
const cx = ((min[0] + max[0]) / 2) * SCALE;
Expand Down Expand Up @@ -283,7 +323,17 @@ const createGame = async () => {
// load the whole glTF scene into the world in one call — the glb
// auto-registered with the level director on preload, exactly like
// a Tiled map. `rightHanded` defaults to true for glTF scenes.
level.load("diorama", { scale: SCALE, onLoaded: setupScene });
// Ground shadows (#1515) for the whole scene in one option: every
// prop gets a blob at its own base — no `shadowGroundY` here,
// because a diorama's props rest on platforms at several different
// heights rather than on one floor. The scene's ground/platform
// meshes are skipped automatically: they have no height to cast
// from, and shadowing them with themselves would smear the terrain.
level.load("diorama", {
scale: SCALE,
castGroundShadow: true,
onLoaded: setupScene,
});
},
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ const MESH_BASE = `${import.meta.env.BASE_URL}assets/mesh3d/`;

// the three props, left to right along X at a common depth
const PROP_Y = 0;
// Where the floor sits, and so where every blob lands. Render space is Y-DOWN,
// so this is BELOW the props at PROP_Y. Chosen to meet their bases (the widest
// prop is 170 across, i.e. 85 below its own origin) — a floor further down
// would be correct but would read as three floating objects, because the
// shadow shrinks and fades with height exactly as it should.
const GROUND_Y = 85;
const PROP_Z = 520;
const SPACING = 210;

Expand Down Expand Up @@ -129,6 +135,30 @@ function buildScene(app: Application) {
world.addChild(key);
world.addChild(new Light3d(0, 0, { type: "ambient", color: "#3b4870" }));

// a floor for the shadows to land on
const F = 900;
const floor = new Mesh(0, GROUND_Y, {
vertices: new Float32Array([-F, 0, -F, F, 0, -F, F, 0, F, -F, 0, F]),
uvs: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]),
indices: new Uint16Array([0, 1, 2, 0, 2, 3]),
normals: new Float32Array([0, -1, 0, 0, -1, 0, 0, -1, 0, 0, -1, 0]),
width: F * 2 * Math.SQRT2,
height: F * 2 * Math.SQRT2,
scale: 1,
normalize: false,
cullBackFaces: false,
// unlit on purpose: the key light points UP in this Y-down scene, so a
// lit ground plane would only ever receive ambient
lit: false,
});
// farther than the props, so the world's depth sort draws it FIRST. A
// shadow does not write depth (that is what lets two overlap), so anything
// drawn after it simply paints over it — a floor sharing the props' depth
// sorts arbitrarily against them and wins half the time.
floor.depth = PROP_Z - 300;
floor.tint.setColor(150, 152, 160);
world.addChild(floor);

const props: Mesh[] = [];

// ── crate: three diffuse maps, one per material ──────────────
Expand All @@ -137,7 +167,12 @@ function buildScene(app: Application) {
material: "crate",
width: 150,
lit: true,
castGroundShadow: true,
shadowGroundY: GROUND_Y,
});
// a quarter turn, so the shipping label faces the camera rather than
// sitting edge-on
crate.rotate(Math.PI / 2, AXIS_Y);
crate.depth = PROP_Z;
props.push(crate);

Expand All @@ -148,6 +183,8 @@ function buildScene(app: Application) {
material: "props",
width: 170,
lit: true,
castGroundShadow: true,
shadowGroundY: GROUND_Y,
});
ball.depth = PROP_Z;
props.push(ball);
Expand All @@ -163,6 +200,8 @@ function buildScene(app: Application) {
// nothing — the two are a pair.
alphaCutoff: 0.5,
cullBackFaces: false,
castGroundShadow: true,
shadowGroundY: GROUND_Y,
});
panel.depth = PROP_Z;
props.push(panel);
Expand Down
Loading