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
30 changes: 9 additions & 21 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,31 +47,19 @@ jobs:
fetch-depth: 0
ref: ${{ env.TARGET_TAG }}

# No install: the trust check is dependency-free TypeScript that node
# strips - the same script the pre-push hook runs before a tag leaves
# the developer's machine.
- uses: actions/setup-node@v6
with:
node-version: '24.x'

- id: resolve
env:
GH_TOKEN: ${{ github.token }}
run: |
sha="$(git rev-parse "${TARGET_TAG}^{commit}")"
echo "sha=$sha" >> "$GITHUB_OUTPUT"

pkg="v$(node -p "require('./package.json').version")"
if [ "$TARGET_TAG" != "$pkg" ]; then
echo "::error::Tag '$TARGET_TAG' does not match package.json version '$pkg'."
exit 1
fi

if ! git merge-base --is-ancestor "$sha" origin/main; then
echo "::error::$TARGET_TAG ($sha) is not on main. Releases are cut from main only."
exit 1
fi

verdict="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${sha}/check-runs?check_name=verdict&per_page=100" \
--jq '[.check_runs[] | select(.conclusion == "success")] | length')"
if [ "$verdict" = "0" ]; then
echo "::error::No successful 'verdict' check run on $sha. Let CI finish on main before tagging."
exit 1
fi
echo "$TARGET_TAG = $sha, on main, verdict green."
echo "sha=$(git rev-parse "${TARGET_TAG}^{commit}")" >> "$GITHUB_OUTPUT"
node scripts/ci/trust.ts "$TARGET_TAG"

# Build every lockstep package exactly once, then pack/hash/attw/consumers/
# full-zip without rebuilding, and upload the result. Any failure here means
Expand Down
47 changes: 29 additions & 18 deletions .husky/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@
# does NOT run is a lane the change cannot affect — a src-only
# change never waits for the audio or tilemap-worker browser
# lanes.
# Tag pushes: full `verify:release` (mirrors CI's verify job, including
# verify:exports + npm pack --dry-run) so a release tag is
# never published until the same checks CI runs have all
# passed locally.
# Tag pushes: `scripts/ci/trust.ts` - the release workflow's own admission
# check: the tagged commit carries the package.json version,
# sits on origin/main and has a green `verdict`. Nothing is
# re-verified locally because CI already ran every lane on the
# push that brought the commit to main; a tag that would be
# rejected remotely is rejected here first.
#
# Also, on branch pushes only: a path-gated `@codexo/exojs-bench` typecheck
# (see the block below verify:quick — it is intentionally NOT part of
Expand All @@ -30,6 +32,7 @@
# <local_ref> <local_sha> <remote_ref> <remote_sha>
is_tag_push=0
is_branch_push=0
pushed_tags=""
push_head_sha=""
push_base_sha=""
null_sha="0000000000000000000000000000000000000000"
Expand All @@ -38,7 +41,10 @@ while read local_ref local_sha remote_ref remote_sha; do
# Deletions carry the null SHA — nothing to verify when removing a ref.
[ "$local_sha" = "$null_sha" ] && continue
case "$remote_ref" in
refs/tags/*) is_tag_push=1 ;;
refs/tags/*)
is_tag_push=1
pushed_tags="$pushed_tags ${remote_ref#refs/tags/}"
;;
*)
is_branch_push=1
# Only the last pushed branch ref wins when a push carries several
Expand All @@ -51,16 +57,16 @@ while read local_ref local_sha remote_ref remote_sha; do
esac
done

# Both verify:quick and verify:release include `typecheck:site`, which
# type-checks the site/examples package against the PUBLISHED entry points
# (package.json `exports` → dist/esm/*.d.ts) rather than against src/. Without
# a build those declaration files don't exist and tsc reports a wall of
# ts(2307) "Cannot find module 'exojs'" — a failure mode that reads like a
# broken import in the pushed change, not like a missing prerequisite. Checking
# for the root entry point's .d.ts up front turns that into one actionable
# line. Deliberately not running the build here: it is slow enough that a hook
# doing it silently would be the thing people bypass.
if [ "$is_tag_push" = "1" ] || [ "$is_branch_push" = "1" ]; then
# verify:quick includes `typecheck:site`, which type-checks the site/examples
# package against the PUBLISHED entry points (package.json `exports` →
# dist/esm/*.d.ts) rather than against src/. Without a build those declaration
# files don't exist and tsc reports a wall of ts(2307) "Cannot find module
# 'exojs'" — a failure mode that reads like a broken import in the pushed
# change, not like a missing prerequisite. Checking for the root entry point's
# .d.ts up front turns that into one actionable line. Deliberately not running
# the build here: it is slow enough that a hook doing it silently would be the
# thing people bypass.
if [ "$is_branch_push" = "1" ]; then
if [ ! -e dist/esm/index.d.ts ]; then
echo "[pre-push] dist/ is missing — 'typecheck:site' would fail with ts(2307) 'Cannot find module exojs'."
echo "[pre-push] It type-checks site/examples against the published entry points, which only exist after a build."
Expand All @@ -70,9 +76,14 @@ if [ "$is_tag_push" = "1" ] || [ "$is_branch_push" = "1" ]; then
fi

if [ "$is_tag_push" = "1" ]; then
echo "[pre-push] tag push detected — running full release verification"
npm run verify:release || exit 1
elif [ "$is_branch_push" = "1" ]; then
echo "[pre-push] tag push detected - checking the tagged commit is releasable"
git fetch --quiet origin main || exit 1
for tag in $pushed_tags; do
node scripts/ci/trust.ts "$tag" || exit 1
done
fi

if [ "$is_branch_push" = "1" ]; then
echo "[pre-push] running verify:quick (static CI-parity gates)"
npm run verify:quick || exit 1

Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and

## [Unreleased]

### Fixed

- **Particle systems on WebGL2 pick up a texture whose payload arrives after
the first draw.** `WebGl2ParticleRenderer` bound the system's texture only
when its identity changed, which for a single-system scene meant exactly
once - while the handle from `loader.get(...)` was still empty. The image
landed a few frames later and never reached the GPU, so the system simulated
and drew its quads against blank pixels for the rest of its life. The same
memo could hold a stale blend mode after another renderer changed it. Both
are now offered to the backend on every system, which already collapses a
redundant bind and is the only holder of the live GL state.

## [0.16.1] - 2026-09-02

### Fixed
Expand Down
29 changes: 19 additions & 10 deletions examples/particles/gpu-particles.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,34 @@
import { Application, Color, FixedResolutionCanvasSizing, RenderBackendType, Scene, Vector } from '@codexo/exojs';
import { AlphaFadeOverLifetime, ApplyForce, ConeDirection, Constant, particlesExtension, ParticleSystem, Range, RateSpawn } from '@codexo/exojs-particles';
import { mountControls } from '@examples/runtime';
// WebGPU runs the whole simulation on a compute shader, so it sustains hundreds
// of thousands of particles smoothly; WebGL2 falls back to a CPU integrator, so
// it uses a much smaller budget to stay at a comfortable frame rate. Both stay
// well within what a modern machine handles without lag.
const budgets = {
webgpu: { capacity: 320_000, rate: 75_000 },
webgl2: { capacity: 20_000, rate: 3_000 },
};
class GpuParticlesScene extends Scene {
system;
hud;
capacity = 0;
init() {
const app = this.app;
const { width, height } = app;
this.system = new ParticleSystem(this.loader.get('image/particle-light.png'), { capacity: CAPACITY });
// Read here rather than beside the Application: a WebGPU request that finds
// no adapter falls back to WebGL2 during start(), so before the scene is
// activated the backend can still be the requested one rather than the one
// that came up - and these two budgets differ sixteenfold.
const isWebGpu = app.backend.backendType === RenderBackendType.WebGpu;
const { capacity, rate } = isWebGpu ? budgets.webgpu : budgets.webgl2;
this.capacity = capacity;
this.system = new ParticleSystem(this.loader.get('image/particle-light.png'), { capacity });
this.systems.add(this.system);
this.system.setPosition(width / 2, height - 80);
this.system.addSpawnModule(
new RateSpawn({
rate: new Constant(RATE),
rate: new Constant(rate),
lifetime: new Range(2.6, 3.8),
velocity: new ConeDirection(-Math.PI / 2, Math.PI / 4, 120, 340),
scale: new Constant(new Vector(0.22, 0.22)),
Expand All @@ -30,7 +46,7 @@ class GpuParticlesScene extends Scene {
}
update(_delta) {
const backend = this.system.gpuMode ? 'WebGPU (GPU compute)' : 'WebGL2 (CPU fallback)';
this.hud.setStatus(`${this.system.aliveCount.toLocaleString()} live / ${CAPACITY.toLocaleString()} cap · ${backend}`);
this.hud.setStatus(`${this.system.aliveCount.toLocaleString()} live / ${this.capacity.toLocaleString()} cap · ${backend}`);
}
draw(context) {
context.render(this.system);
Expand All @@ -50,11 +66,4 @@ const app = new Application({
},
extensions: [particlesExtension],
});
// WebGPU runs the whole simulation on a compute shader, so it sustains hundreds
// of thousands of particles smoothly; WebGL2 falls back to a CPU integrator, so
// it uses a much smaller budget to stay at a comfortable frame rate. Both stay
// well within what a modern machine handles without lag.
const isWebGpu = app.backend.backendType === RenderBackendType.WebGpu;
const CAPACITY = isWebGpu ? 320_000 : 20_000;
const RATE = isWebGpu ? 75_000 : 3_000;
await app.start(GpuParticlesScene);
31 changes: 20 additions & 11 deletions examples/particles/gpu-particles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,37 @@ import { Application, Color, FixedResolutionCanvasSizing, RenderBackendType, typ
import { AlphaFadeOverLifetime, ApplyForce, ConeDirection, Constant, particlesExtension, ParticleSystem, Range, RateSpawn } from '@codexo/exojs-particles';
import { mountControls } from '@examples/runtime';

// WebGPU runs the whole simulation on a compute shader, so it sustains hundreds
// of thousands of particles smoothly; WebGL2 falls back to a CPU integrator, so
// it uses a much smaller budget to stay at a comfortable frame rate. Both stay
// well within what a modern machine handles without lag.
const budgets = {
webgpu: { capacity: 320_000, rate: 75_000 },
webgl2: { capacity: 20_000, rate: 3_000 },
};

class GpuParticlesScene extends Scene {
private system!: ParticleSystem;
private hud!: ReturnType<typeof mountControls>;
private capacity = 0;

override init(): void {
const app = this.app;
const { width, height } = app;
// Read here rather than beside the Application: a WebGPU request that finds
// no adapter falls back to WebGL2 during start(), so before the scene is
// activated the backend can still be the requested one rather than the one
// that came up - and these two budgets differ sixteenfold.
const isWebGpu = app.backend.backendType === RenderBackendType.WebGpu;
const { capacity, rate } = isWebGpu ? budgets.webgpu : budgets.webgl2;

this.system = new ParticleSystem(this.loader.get('image/particle-light.png'), { capacity: CAPACITY });
this.capacity = capacity;
this.system = new ParticleSystem(this.loader.get('image/particle-light.png'), { capacity });
this.systems.add(this.system);
this.system.setPosition(width / 2, height - 80);
this.system.addSpawnModule(
new RateSpawn({
rate: new Constant(RATE),
rate: new Constant(rate),
lifetime: new Range(2.6, 3.8),
velocity: new ConeDirection(-Math.PI / 2, Math.PI / 4, 120, 340),
scale: new Constant(new Vector(0.22, 0.22)),
Expand All @@ -35,7 +52,7 @@ class GpuParticlesScene extends Scene {
override update(_delta: Seconds): void {
const backend = this.system.gpuMode ? 'WebGPU (GPU compute)' : 'WebGL2 (CPU fallback)';

this.hud.setStatus(`${this.system.aliveCount.toLocaleString()} live / ${CAPACITY.toLocaleString()} cap · ${backend}`);
this.hud.setStatus(`${this.system.aliveCount.toLocaleString()} live / ${this.capacity.toLocaleString()} cap · ${backend}`);
}

override draw(context: RenderingContext): void {
Expand All @@ -58,12 +75,4 @@ const app = new Application({
extensions: [particlesExtension],
});

// WebGPU runs the whole simulation on a compute shader, so it sustains hundreds
// of thousands of particles smoothly; WebGL2 falls back to a CPU integrator, so
// it uses a much smaller budget to stay at a comfortable frame rate. Both stay
// well within what a modern machine handles without lag.
const isWebGpu = app.backend.backendType === RenderBackendType.WebGpu;
const CAPACITY = isWebGpu ? 320_000 : 20_000;
const RATE = isWebGpu ? 75_000 : 3_000;

await app.start(GpuParticlesScene);
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@
"verify:create-exo-app": "tsx ./scripts/verify-create-exo-app.ts",
"sync:example-capabilities": "tsx ./scripts/sync-example-capabilities.ts",
"create:package": "tsx scripts/create-package.ts",
"verify:release": "pnpm verify:lockstep && pnpm typecheck && pnpm typecheck:guides && pnpm typecheck:examples && pnpm lint:all && pnpm format:check && pnpm test && pnpm verify:package && pnpm verify:create-exo-app && pnpm site:build",
"gate:bench:structural": "pnpm --filter @codexo/exojs-bench gate:structural",
"gates": "tsx ./scripts/ci/gates.ts",
"lanes": "tsx ./scripts/lanes.ts",
Expand Down
25 changes: 8 additions & 17 deletions packages/exojs-particles/src/renderers/WebGl2ParticleRenderer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import type { AttributeType, GeometryUsage, Material, Topology } from '@codexo/exojs';
import type { BlendModes } from '@codexo/exojs/renderer-sdk';
import type { Texture } from '@codexo/exojs/renderer-sdk';
import type { View } from '@codexo/exojs/renderer-sdk';
import type { WebGl2Backend } from '@codexo/exojs/renderer-sdk';
import { BufferTypes, BufferUsage, RenderingPrimitives } from '@codexo/exojs/renderer-sdk';
Expand Down Expand Up @@ -132,8 +130,6 @@ export class WebGl2ParticleRenderer extends AbstractWebGl2Renderer<ParticleSyste
private _drawCount = 0;
private _pendingMode: ParticleRenderMode | null = null;
private _pendingResources: ParticleModeResources | null = null;
private _currentTexture: Texture | null = null;
private _currentBlendMode: BlendModes | null = null;
private _connection: ParticleRendererConnection | null = null;

public constructor(batchSize: number) {
Expand All @@ -145,23 +141,20 @@ export class WebGl2ParticleRenderer extends AbstractWebGl2Renderer<ParticleSyste
public render(system: ParticleSystem): this {
const backend = this.getBackend();
const { texture, blendMode } = system;
const textureChanged = texture !== this._currentTexture;
const blendModeChanged = blendMode !== this._currentBlendMode;

// System transform / texture / UV / local-bounds are uniforms, so
// mixing systems in one batch is invalid. Flush any prior system
// before setting up this one.
this.flush();

if (textureChanged) {
this._currentTexture = texture;
backend.bindTexture(texture);
}

if (blendModeChanged) {
this._currentBlendMode = blendMode;
backend.setBlendMode(blendMode);
}
// Offered to the backend on every system, never filtered against what this
// renderer bound last: the backend is the only holder of the live GL state
// (another renderer can have taken the unit or the blend mode since), and
// it alone sees a texture whose payload changed under a stable identity -
// which is what a handle from the loader does the moment its download
// lands. Both calls collapse to nothing when the state already matches.
backend.bindTexture(texture);
backend.setBlendMode(blendMode);

const mode = system.renderMode;
const resources = this._getOrCreateResources(mode);
Expand Down Expand Up @@ -256,8 +249,6 @@ export class WebGl2ParticleRenderer extends AbstractWebGl2Renderer<ParticleSyste

this._resources.clear();
this._connection = null;
this._currentTexture = null;
this._currentBlendMode = null;
this._drawCount = 0;
this._pendingMode = null;
this._pendingResources = null;
Expand Down
55 changes: 55 additions & 0 deletions scripts/ci/trust.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';

/**
* Decides whether a release tag may be published without re-running CI: the
* tagged commit must carry the package.json version, sit on `main`, and have a
* green `verdict` check run - the required check that only passes when every
* lane the plan asked for succeeded on the push that brought the commit there.
*
* The Release workflow's `trust` job and the pre-push hook's tag path both run
* this, so what a developer's push accepts is exactly what the workflow
* accepts. Dependency-free and type-strippable like `lanes.ts`: plain `node`
* runs it before any install.
*
* Usage: node scripts/ci/trust.ts <tag> [--main <ref>]
* --main defaults to `origin/main`; the caller fetches it first.
*/

const args = process.argv.slice(2);
const tag = args.find(arg => !arg.startsWith('--'));
const mainIndex = args.indexOf('--main');
const mainRef = mainIndex === -1 ? 'origin/main' : (args[mainIndex + 1] ?? 'origin/main');

const fail: (message: string) => never = message => {
process.stdout.write(`${process.env['GITHUB_ACTIONS'] ? '::error::' : '[trust] '}${message}\n`);
process.exit(1);
};

const capture = (command: string, commandArgs: readonly string[]): string | null => {
const result = spawnSync(command, commandArgs, { encoding: 'utf8' });
return result.status === 0 ? result.stdout.trim() : null;
};

if (!tag) fail('usage: node scripts/ci/trust.ts <tag> [--main <ref>]');

const sha = capture('git', ['rev-parse', `${tag}^{commit}`]);
if (!sha) fail(`Tag '${tag}' does not exist locally.`);

const version = `v${(JSON.parse(readFileSync('package.json', 'utf8')) as { version: string }).version}`;
if (tag !== version) fail(`Tag '${tag}' does not match package.json version '${version}'.`);

const onMain = spawnSync('git', ['merge-base', '--is-ancestor', sha, mainRef], { stdio: 'ignore' });
if (onMain.status !== 0) fail(`${tag} (${sha}) is not on ${mainRef}. Releases are cut from main only.`);

const repository = process.env['GITHUB_REPOSITORY'] ?? '{owner}/{repo}';
const greenVerdicts = capture('gh', [
'api',
`repos/${repository}/commits/${sha}/check-runs?check_name=verdict&per_page=100`,
'--jq',
'[.check_runs[] | select(.conclusion == "success")] | length',
]);
if (greenVerdicts === null) fail("Could not read the tag commit's check runs - is `gh` installed and authenticated?");
if (greenVerdicts === '0') fail(`No successful 'verdict' check run on ${sha}. Let CI finish on main before tagging.`);

process.stdout.write(`${tag} = ${sha}, on ${mainRef}, verdict green.\n`);
7 changes: 5 additions & 2 deletions scripts/release/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,11 @@ version in the tree indefinitely).
git push && git push origin refs/tags/vx.y.z
```

7. **Watch the CI.** The `Release` workflow checks out the **tag commit**, runs the
full CI gate, builds once, packs/hashes/attw/consumer-tests the tarballs, and
7. **Watch the CI.** The pre-push hook already ran `scripts/ci/trust.ts` on the
tag: the tagged commit carries the package.json version, sits on `main` and
has a green `verdict` check. The `Release` workflow repeats that check in its
`trust` job instead of re-running CI, then checks out the **tag commit**,
builds once, packs/hashes/attw/consumer-tests the tarballs, and
publishes them directly to the `latest` dist-tag via OIDC in lockstep order
(Core first, then the extensions). Every tarball is `attw`-checked; the offline
consumer smoke covers all packages **except `@codexo/exojs-react`** (its
Expand Down
Loading
Loading