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
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ jobs:
run: npx playwright install --with-deps chromium

- name: Production browser smoke
run: npm run test:e2e:release
run: npm run test:e2e:built

- name: Rendering regressions
run: npm run test:e2e -- tests/e2e/render-work.spec.ts --project desktop-chromium

- name: Online pairing browser flow
run: npm run test:online:e2e
Expand Down
34 changes: 20 additions & 14 deletions components/xiangqi/ai/engine-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,22 +152,26 @@ function normalizedMime(value: string | null): string {
);
}

async function fetchWithTimeout(
async function fetchWithTimeout<T>(
fetcher: NonNullable<MasterEngineAssetLoaderOptions["fetcher"]>,
input: RequestInfo | URL,
init: RequestInit,
timeoutMs: number,
): Promise<Response> {
consume: (response: Response) => Promise<T>,
): Promise<T> {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<Response>((_resolve, reject) => {
const timeout = new Promise<T>((_resolve, reject) => {
timer = setTimeout(() => {
controller.abort();
reject(new Error(`Master engine asset request timed out after ${timeoutMs} ms.`));
}, timeoutMs);
});
try {
return await Promise.race([fetcher(input, { ...init, signal: controller.signal }), timeout]);
return await Promise.race([
fetcher(input, { ...init, signal: controller.signal }).then(consume),
timeout,
]);
} finally {
if (timer !== undefined) clearTimeout(timer);
}
Expand Down Expand Up @@ -263,22 +267,23 @@ async function loadVerifiedMasterAssetsUnshared(
if (!cacheStorage) throw new Error("Cache Storage is unavailable for Master engine assets.");

const manifestUrl = new URL(MASTER_ENGINE_MANIFEST_URL, origin).href;
const manifestResponse = await fetchWithTimeout(
const manifestBytes = await fetchWithTimeout(
fetcher,
manifestUrl,
{
cache: "no-cache",
credentials: "same-origin",
},
fetchTimeoutMs,
async (response) => {
if (!response.ok) throw new Error(`Master manifest returned HTTP ${response.status}.`);
const mime = normalizedMime(response.headers.get("content-type"));
if (mime !== "application/json; charset=utf-8" && mime !== "application/json") {
throw new Error(`Master manifest MIME differs: ${mime || "missing"}.`);
}
return response.arrayBuffer();
},
);
if (!manifestResponse.ok)
throw new Error(`Master manifest returned HTTP ${manifestResponse.status}.`);
const manifestMime = normalizedMime(manifestResponse.headers.get("content-type"));
if (manifestMime !== "application/json; charset=utf-8" && manifestMime !== "application/json") {
throw new Error(`Master manifest MIME differs: ${manifestMime || "missing"}.`);
}
const manifestBytes = await manifestResponse.arrayBuffer();
let manifestValue: unknown;
try {
manifestValue = JSON.parse(new TextDecoder().decode(manifestBytes));
Expand Down Expand Up @@ -307,13 +312,14 @@ async function loadVerifiedMasterAssetsUnshared(
try {
for (const record of manifest.runtimeFiles) {
const url = new URL(record.name, new URL(manifest.runtimeBaseUrl, origin)).href;
const response = await fetchWithTimeout(
const bytes = await fetchWithTimeout(
fetcher,
url,
{ cache: "no-cache", credentials: "same-origin" },
fetchTimeoutMs,
(response) => verifyRuntimeResponse(response, record, digest),
);
fetched.set(record.name, await verifyRuntimeResponse(response, record, digest));
fetched.set(record.name, bytes);
}
const cache = await cacheStorage.open(name);
for (const record of manifest.runtimeFiles) {
Expand Down
8 changes: 6 additions & 2 deletions components/xiangqi/game/GameBoardLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
/* oxlint-disable react/no-unknown-property -- R3F scene graph props are valid custom JSX properties. */

import type { ThreeEvent } from "@react-three/fiber";
import { memo, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from "react";
import { memo, useCallback, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from "react";
import * as THREE from "three";

import {
Expand Down Expand Up @@ -382,6 +382,10 @@ export function GameBoardLayer({
presentation.getSnapshot,
presentation.getSnapshot,
);
const handlePiecePress = useCallback(
(piece: Piece) => onSquarePress(piece.square),
[onSquarePress],
);
const lastMove = game.history.at(-1) ?? null;
const active = visual.active;
const moveEvent = active?.transition.events.find(
Expand Down Expand Up @@ -489,7 +493,7 @@ export function GameBoardLayer({
: 0
}
lod={lod}
onPress={(piece) => onSquarePress(piece.square)}
onPress={handlePiecePress}
piece={slot.data}
selected={slot.id === selectedPieceId}
/>
Expand Down
6 changes: 3 additions & 3 deletions components/xiangqi/pieces/PieceActor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
/* oxlint-disable react/no-unknown-property -- R3F scene graph props are valid custom JSX properties. */

import type { ThreeEvent } from "@react-three/fiber";
import { useEffect, useMemo } from "react";
import { memo, useEffect, useMemo } from "react";
import * as THREE from "three";
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";

Expand Down Expand Up @@ -175,7 +175,7 @@ function RiggedRoleModel({
return <primitive object={prepared.model} position={[0, prepared.localY, 0]} />;
}

export function PieceActor({
export const PieceActor = memo(function PieceActor({
actorId,
animation = "idle_loop",
animations,
Expand Down Expand Up @@ -222,4 +222,4 @@ export function PieceActor({
/>
</group>
);
}
});
13 changes: 10 additions & 3 deletions components/xiangqi/runtime/FrameScheduler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ export type ScheduledFrameRegistration = Readonly<{
}>;
type RegisterFrame = (task: ScheduledFrame, onError?: ScheduledFrameErrorHandler) => () => void;

const FrameSchedulerContext = createContext<RegisterFrame | null>(null);
const FrameSchedulerContext = createContext<{
register: RegisterFrame;
hasTasks: () => boolean;
} | null>(null);
const FRAME_INTERVAL_TOLERANCE_MS = 0.75;

export function isScheduledFrameDue(elapsedMs: number, ambientFps: number) {
Expand Down Expand Up @@ -153,16 +156,20 @@ export function FrameScheduler({
};
}, [ambientFps, gl, invalidate]);

const value = useMemo(() => register, [register]);
const value = useMemo(() => ({ register, hasTasks: () => tasks.current.size > 0 }), [register]);
return <FrameSchedulerContext.Provider value={value}>{children}</FrameSchedulerContext.Provider>;
}

export function useHasScheduledFrames() {
return useContext(FrameSchedulerContext)?.hasTasks;
}

export function useScheduledFrame(
task: ScheduledFrame,
enabled = true,
onError?: ScheduledFrameErrorHandler,
) {
const register = useContext(FrameSchedulerContext);
const register = useContext(FrameSchedulerContext)?.register;

useEffect(() => {
if (!enabled) return;
Expand Down
22 changes: 19 additions & 3 deletions components/xiangqi/runtime/PerformanceSummary.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"use client";

import { useFrame, useThree } from "@react-three/fiber";
import { addTail, useFrame, useThree } from "@react-three/fiber";
import { useEffect, useRef, type RefObject } from "react";

import { useHasScheduledFrames } from "./FrameScheduler";
import { PerformanceMetrics, type RuntimePerformanceSnapshot } from "./performance-metrics";

declare global {
Expand All @@ -18,13 +19,25 @@ export function PerformanceSummary({
drawCallsRef: RefObject<HTMLSpanElement | null>;
}) {
const lastUpdate = useRef(0);
const continuousFrame = useRef(false);
const lastRendererTotals = useRef({ drawCalls: 0, triangles: 0 });
const metrics = useRef(new PerformanceMetrics());
const gl = useThree((state) => state.gl);
const hasScheduledFrames = useHasScheduledFrames();

useEffect(() => {
const activeMetrics = metrics.current;
const resetContinuity = () => {
continuousFrame.current = false;
};
// Ambient tasks can keep animating across stops in R3F's demand loop.
// Only a stop without scheduled work is idle; slow active frames still count.
const removeTail = addTail(() => {
if (!hasScheduledFrames?.()) resetContinuity();
});
document.addEventListener("visibilitychange", resetContinuity);
const publishEmptySnapshot = () => {
resetContinuity();
activeMetrics.reset();
gl.info.reset();
lastRendererTotals.current = { drawCalls: 0, triangles: 0 };
Expand All @@ -33,12 +46,14 @@ export function PerformanceSummary({
window.__XIANGQI_RESET_PERFORMANCE__ = publishEmptySnapshot;
publishEmptySnapshot();
return () => {
removeTail();
document.removeEventListener("visibilitychange", resetContinuity);
if (window.__XIANGQI_RESET_PERFORMANCE__ === publishEmptySnapshot) {
delete window.__XIANGQI_RESET_PERFORMANCE__;
delete window.__XIANGQI_PERFORMANCE__;
}
};
}, [gl]);
}, [gl, hasScheduledFrames]);

useFrame(({ clock, gl: frameGl }, deltaSeconds) => {
const drawCalls = Math.max(0, frameGl.info.render.calls - lastRendererTotals.current.drawCalls);
Expand All @@ -52,11 +67,12 @@ export function PerformanceSummary({
};
metrics.current.record({
drawCalls,
frameIntervalMs: deltaSeconds * 1_000,
frameIntervalMs: continuousFrame.current && !document.hidden ? deltaSeconds * 1_000 : 0,
geometries: frameGl.info.memory.geometries,
textures: frameGl.info.memory.textures,
triangles,
});
continuousFrame.current = !document.hidden;
if (clock.elapsedTime - lastUpdate.current < 0.5) return;
lastUpdate.current = clock.elapsedTime;
const snapshot = metrics.current.snapshot();
Expand Down
6 changes: 1 addition & 5 deletions components/xiangqi/runtime/performance-metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,7 @@ export class PerformanceMetrics {
this.current = sample;
this.peakDrawCalls = Math.max(this.peakDrawCalls, sample.drawCalls);
this.peakTriangles = Math.max(this.peakTriangles, sample.triangles);
if (
Number.isFinite(sample.frameIntervalMs) &&
sample.frameIntervalMs > 0 &&
sample.frameIntervalMs <= 250
) {
if (Number.isFinite(sample.frameIntervalMs) && sample.frameIntervalMs > 0) {
this.intervals.push(sample.frameIntervalMs);
while (this.intervals.length > Math.max(1, this.maximumSamples)) this.intervals.shift();
}
Expand Down
31 changes: 23 additions & 8 deletions components/xiangqi/scene/BattlePostprocessing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,31 @@
/* oxlint-disable react/no-unknown-property -- R3F scene graph props are valid custom JSX properties. */

import { EffectComposer, SelectiveBloom } from "@react-three/postprocessing";
import { useThree } from "@react-three/fiber";
import { useMemo } from "react";
import * as THREE from "three";

/** High-tier-only selective glow. Only objects wrapped in <Select> enter this pass. */
// EffectComposer mounts its children after creating its renderer resources.
function BattleBloom({ light }: { light: THREE.Light }) {
const scene = useThree((state) => state.scene);
const selection = useMemo(
() =>
scene.getObjectByName("battle-bloom-selection")?.getObjectsByProperty("type", "Mesh") ?? [],
[scene],
);
return (
<SelectiveBloom
selection={selection}
intensity={0.62}
lights={[light]}
luminanceSmoothing={0.18}
luminanceThreshold={0.28}
radius={0.46}
/>
);
}

/** High-tier-only glow, loaded independently of the board and its VFX graph. */
export function BattlePostprocessing() {
const bloomLight = useMemo(() => {
const light = new THREE.AmbientLight(0xffffff, 0.72);
Expand All @@ -18,13 +39,7 @@ export function BattlePostprocessing() {
<>
<primitive object={bloomLight} />
<EffectComposer depthBuffer enableNormalPass={false} multisampling={0} resolutionScale={0.65}>
<SelectiveBloom
intensity={0.62}
lights={[bloomLight]}
luminanceSmoothing={0.18}
luminanceThreshold={0.28}
radius={0.46}
/>
<BattleBloom light={bloomLight} />
</EffectComposer>
</>
);
Expand Down
Loading
Loading