feat(s4): add opt-in webgl render surface

- 新增 packages/web-runtime 渲染面抽象 RenderSurface + Canvas2D/WebGL 两实现 + createRenderSurface 工厂。
- WebGL 仅作内部可替换渲染器,挂在 RuntimeSdkContract.Canvas.submitRenderCommands 接缝后;
  不改 RuntimeSdkContract / GameLogicModule,逻辑模块拿不到 GL/canvas/2d 句柄。
- Canvas2D 仍为默认;?renderer=webgl 显式 opt-in(page 改 async server 组件 +
  新增 PreviewRuntimeClient 客户端包装),WebGL 不可用/初始化失败自动回退 Canvas2D 并产出诊断。
- 新增/扩展单测:WebGL clear/rect/text 非空、可交互、回退+诊断;createRenderSurface 选择;
  WebPlatformAdapter renderer 选择/回退;preview opt-in 决策;controller stop()/失败 catch 接线 dispose。
- docs/memorys:WebPlayable 里程碑审计 + MVP 剩余清单与路线。
  S6/S7/S8 仍 not_started;微信/抖音 DevTools 导入认证仍 No-Go,未伪造任何 passed evidence。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-05 23:47:58 +00:00
parent 98e2aad155
commit 7c7879cfe8
17 changed files with 1923 additions and 98 deletions

View File

@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { readRendererPreference } from "./page";
// 预览页 opt-in 渲染器决策单测:把 ?renderer 查询参数收敛为 "webgl" 或 undefined默认 canvas2d
// 该决策是 WebGL opt-in 的入口,必须默认安全(仅显式 webgl 才切换)。
describe("preview page renderer opt-in", () => {
it("仅当 ?renderer=webgl 时返回 webgl", () => {
expect(readRendererPreference("webgl")).toBe("webgl");
});
it("查询重复(数组)时取第一个值", () => {
expect(readRendererPreference(["webgl", "canvas2d"])).toBe("webgl");
});
it("任何非 webgl 取值都回到默认undefined", () => {
expect(readRendererPreference("canvas2d")).toBeUndefined();
expect(readRendererPreference("webgl2")).toBeUndefined();
expect(readRendererPreference(["canvas2d", "webgl"])).toBeUndefined();
});
it("缺省(无 query回到默认undefined", () => {
expect(readRendererPreference(undefined)).toBeUndefined();
});
});

View File

@ -1,65 +1,32 @@
"use client";
import type { CSSProperties } from "react";
import { createElement } from "react";
import { WebGameRuntime, getPreviewPackageSource } from "../../../components/runtime/WebGameRuntime";
import { PreviewRuntimeClient } from "../../../components/runtime/PreviewRuntimeClient";
// Next.js 16page 的 params/searchParams 都是 Promise。本页是 server 组件async
// 在服务端 await 解包,读取 opt-in 渲染器(?renderer=webgl再把纯值 props 交给 client 组件。
type PreviewPageProps = {
readonly params: {
readonly params: Promise<{
readonly versionId: string;
};
}>;
readonly searchParams: Promise<{
readonly renderer?: string | readonly string[];
}>;
};
const styles = {
diagnostic: {
background: "#fff7ed",
border: "1px solid #fed7aa",
borderRadius: 6,
color: "#7c2d12",
fontSize: 13,
lineHeight: 1.5,
margin: 0,
maxWidth: 760,
padding: 12
},
main: {
color: "#18231f",
display: "grid",
fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif",
gap: 18,
margin: "0 auto",
maxWidth: 980,
padding: 24
},
meta: {
color: "#52655c",
fontSize: 13,
lineHeight: 1.5,
margin: 0
},
title: {
color: "#111c17",
fontSize: 28,
lineHeight: 1.2,
margin: 0
}
} satisfies Record<string, CSSProperties>;
export default async function PreviewRuntimePage({ params, searchParams }: PreviewPageProps) {
const { versionId } = await params;
const { renderer } = await searchParams;
// opt-in仅当 ?renderer=webgl 时请求 WebGL否则维持默认 canvas2d。
const rendererPreference = readRendererPreference(renderer);
export default function PreviewRuntimePage({ params }: PreviewPageProps) {
const { versionId } = params;
const previewPackage = getPreviewPackageSource(versionId);
return createElement(
"main",
{ style: styles.main },
createElement("h1", { style: styles.title }, "Web runtime preview"),
createElement("p", { style: styles.meta }, `Version ${versionId}`),
previewPackage.kind === "ready"
? createElement(WebGameRuntime, {
gamePackage: previewPackage.gamePackage,
loadLogicModule: previewPackage.logicLoader,
packageReader: previewPackage.packageReader
})
: createElement("p", { style: styles.diagnostic }, previewPackage.message)
);
return createElement(PreviewRuntimeClient, {
versionId,
...(rendererPreference === undefined ? {} : { rendererPreference })
});
}
// readRendererPreference把查询参数收敛为 "webgl" 或 undefined缺省即 canvas2d
// 数组形式(重复 query取第一个值任何非 "webgl" 取值都视为默认。导出以便单测覆盖 opt-in 决策。
export function readRendererPreference(renderer: string | readonly string[] | undefined): "webgl" | undefined {
const value = Array.isArray(renderer) ? renderer[0] : renderer;
return value === "webgl" ? "webgl" : undefined;
}

View File

@ -0,0 +1,69 @@
"use client";
import type { CSSProperties } from "react";
import { createElement } from "react";
import { WebGameRuntime, getPreviewPackageSource } from "./WebGameRuntime";
// PreviewRuntimeClient预览页的 client 组件。
// 之所以独立成 client 组件getPreviewPackageSource 会创建 logicLoader/packageReader 等函数,
// 这些函数只能在 client 侧创建、不能跨 server/client 边界传递server 组件只把 versionId + 可选 rendererPreference纯值传进来。
type PreviewRuntimeClientProps = {
readonly versionId: string;
// opt-in 渲染器偏好:缺省 undefined => 默认 canvas2d。
readonly rendererPreference?: "webgl";
};
const styles = {
diagnostic: {
background: "#fff7ed",
border: "1px solid #fed7aa",
borderRadius: 6,
color: "#7c2d12",
fontSize: 13,
lineHeight: 1.5,
margin: 0,
maxWidth: 760,
padding: 12
},
main: {
color: "#18231f",
display: "grid",
fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif",
gap: 18,
margin: "0 auto",
maxWidth: 980,
padding: 24
},
meta: {
color: "#52655c",
fontSize: 13,
lineHeight: 1.5,
margin: 0
},
title: {
color: "#111c17",
fontSize: 28,
lineHeight: 1.2,
margin: 0
}
} satisfies Record<string, CSSProperties>;
export function PreviewRuntimeClient({ versionId, rendererPreference }: PreviewRuntimeClientProps) {
const previewPackage = getPreviewPackageSource(versionId);
return createElement(
"main",
{ style: styles.main },
createElement("h1", { style: styles.title }, "Web runtime preview"),
createElement("p", { style: styles.meta }, `Version ${versionId}`),
previewPackage.kind === "ready"
? createElement(WebGameRuntime, {
gamePackage: previewPackage.gamePackage,
loadLogicModule: previewPackage.logicLoader,
packageReader: previewPackage.packageReader,
// 仅在显式 opt-in 时透传,缺省保持默认 canvas2d。
...(rendererPreference === undefined ? {} : { rendererPreference })
})
: createElement("p", { style: styles.diagnostic }, previewPackage.message)
);
}

View File

@ -3,7 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import type { TelemetryEventDto } from "../../../../../packages/shared-contracts/src/runtime-sdk-contract";
import type { WebRuntimeLogicModule } from "../../../../../packages/web-runtime/src/WebRuntimeHost";
import PreviewRuntimePage from "../../app/preview/[versionId]/page";
import { PreviewRuntimeClient } from "./PreviewRuntimeClient";
import {
WebGameRuntime,
createPreviewRuntimeController,
@ -148,21 +148,29 @@ describe("S4 WebGameRuntime preview UI", () => {
expect(shouldApplyRuntimeSnapshot({ activeRunId: 2, candidateRunId: 2, mounted: true })).toBe(true);
});
it("preview page uses versionId to render fixture runtime or diagnostic output", async () => {
it("preview client uses versionId to render fixture runtime or diagnostic output", async () => {
const readyHtml = renderToStaticMarkup(
createElement(PreviewRuntimePage, {
params: { versionId: "simulation-fixture-v1" }
createElement(PreviewRuntimeClient, {
versionId: "simulation-fixture-v1"
})
);
const missingHtml = renderToStaticMarkup(
createElement(PreviewRuntimePage, {
params: { versionId: "missing-version" }
createElement(PreviewRuntimeClient, {
versionId: "missing-version"
})
);
// opt-in WebGL 偏好仍渲染 runtime 容器(默认 loading 态),不破坏既有渲染。
const webglHtml = renderToStaticMarkup(
createElement(PreviewRuntimeClient, {
versionId: "simulation-fixture-v1",
rendererPreference: "webgl"
})
);
expect(readyHtml).toContain("Version simulation-fixture-v1");
expect(readyHtml).toContain("data-runtime-status=\"loading\"");
expect(missingHtml).toContain("Preview package missing-version is not available from the current fixture loader.");
expect(webglHtml).toContain("data-runtime-status=\"loading\"");
});
it("preview package registry returns one shared source for page and component", () => {

View File

@ -16,6 +16,9 @@ type WebWindowTarget = WebPlatformAdapterOptions["windowTarget"];
type WebStorage = WebPlatformAdapterOptions["storage"];
type WebFetcher = WebPlatformAdapterOptions["fetcher"];
// 渲染器偏好opt-in缺省 undefined => 适配器内部按 "canvas2d" 处理,默认行为不变。
type RendererPreference = WebPlatformAdapterOptions["rendererPreference"];
export type PreviewRuntimeStatus = "loading" | "running" | "failed";
export type PreviewRuntimeSnapshot = {
@ -30,6 +33,8 @@ export type PreviewRuntimeControllerInput = {
readonly gamePackage: GamePackage;
readonly loadLogicModule?: ((packageLogicPath: string) => Promise<WebRuntimeLogicModule>) | undefined;
readonly reader?: WebRuntimeHostPackageReader | undefined;
// 渲染器偏好opt-in缺省时维持默认 canvas2d。
readonly rendererPreference?: RendererPreference;
readonly scheduler?: WebRuntimeHostScheduler;
readonly storage: WebStorage;
readonly telemetrySender?: ((events: readonly TelemetryEventDto[]) => Promise<void>) | undefined;
@ -49,6 +54,8 @@ type WebGameRuntimeProps = {
readonly initialSnapshot?: PreviewRuntimeSnapshot;
readonly loadLogicModule?: ((packageLogicPath: string) => Promise<WebRuntimeLogicModule>) | undefined;
readonly packageReader?: WebRuntimeHostPackageReader | undefined;
// 渲染器偏好opt-in缺省时默认 canvas2d由预览页根据查询参数传入。
readonly rendererPreference?: RendererPreference;
};
const PREVIEW_CONFIG_TEXT = `{
@ -198,7 +205,7 @@ const styles = {
}
} satisfies Record<string, CSSProperties>;
export function WebGameRuntime({ gamePackage, initialSnapshot, loadLogicModule, packageReader }: WebGameRuntimeProps) {
export function WebGameRuntime({ gamePackage, initialSnapshot, loadLogicModule, packageReader, rendererPreference }: WebGameRuntimeProps) {
const [snapshot, setSnapshot] = useState<PreviewRuntimeSnapshot>(initialSnapshot ?? createSnapshot("loading"));
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const controllerRef = useRef<PreviewRuntimeController | null>(null);
@ -207,9 +214,10 @@ export function WebGameRuntime({ gamePackage, initialSnapshot, loadLogicModule,
() => ({
gamePackage,
loadLogicModule,
reader: packageReader
reader: packageReader,
rendererPreference
}),
[gamePackage, loadLogicModule, packageReader]
[gamePackage, loadLogicModule, packageReader, rendererPreference]
);
useEffect(() => {
@ -225,6 +233,7 @@ export function WebGameRuntime({ gamePackage, initialSnapshot, loadLogicModule,
gamePackage: packageInput.gamePackage,
loadLogicModule: packageInput.loadLogicModule,
reader: packageInput.reader,
rendererPreference: packageInput.rendererPreference,
storage: window.localStorage,
windowTarget: window
});
@ -256,6 +265,7 @@ export function WebGameRuntime({ gamePackage, initialSnapshot, loadLogicModule,
gamePackage: packageInput.gamePackage,
loadLogicModule: packageInput.loadLogicModule,
reader: packageInput.reader,
rendererPreference: packageInput.rendererPreference,
storage: window.localStorage,
windowTarget: window
});
@ -290,6 +300,8 @@ export function WebGameRuntime({ gamePackage, initialSnapshot, loadLogicModule,
export function createPreviewRuntimeController(input: PreviewRuntimeControllerInput): PreviewRuntimeController {
let host: WebRuntimeHost | null = null;
// 保留本次运行创建的适配器实例引用stop() 时显式 dispose 释放渲染面资源WebGL 程序/缓冲/纹理)。
let adapter: WebPlatformAdapter | null = null;
let telemetry: TelemetryBuffer | null = null;
let currentStatus: PreviewRuntimeStatus = "loading";
const diagnostics: string[] = [];
@ -320,14 +332,19 @@ export function createPreviewRuntimeController(input: PreviewRuntimeControllerIn
if (input.reader === undefined) throw new Error("WEB_PREVIEW_PACKAGE_READER_MISSING");
if (input.loadLogicModule === undefined) throw new Error("WEB_PREVIEW_LOGIC_LOADER_MISSING");
const hostOptions = {
createAdapter: () =>
new WebPlatformAdapter({
createAdapter: () => {
// 先实例化适配器并保留引用(供 stop() dispose再返回其 SDK 供宿主使用。
adapter = new WebPlatformAdapter({
canvasElement: input.canvasElement,
fetcher: input.fetcher,
storage: input.storage,
telemetry: requireTelemetry(),
windowTarget: input.windowTarget
}).createSdk(),
windowTarget: input.windowTarget,
// opt-in 渲染器偏好;缺省时适配器内部按 canvas2d 处理,默认不变。
...(input.rendererPreference === undefined ? {} : { rendererPreference: input.rendererPreference })
});
return adapter.createSdk();
},
expectedManifestChecksum: requireWebManifestChecksum(input.gamePackage),
fixedTickMs: 16,
gameVersionId: input.gamePackage.gameVersionId,
@ -342,6 +359,9 @@ export function createPreviewRuntimeController(input: PreviewRuntimeControllerIn
} catch (error) {
host?.stop();
host = null;
// 加载失败也要释放已创建的适配器渲染面资源,避免失败态滞留到下次 load/卸载前的短暂泄漏。
adapter?.dispose();
adapter = null;
diagnostics.push(diagnosticFrom(error));
await flushTelemetry();
currentStatus = "failed";
@ -369,6 +389,9 @@ export function createPreviewRuntimeController(input: PreviewRuntimeControllerIn
function stop(): void {
host?.stop();
host = null;
// 释放本次运行适配器持有的渲染面资源WebGL 程序/缓冲/纹理Canvas2D 渲染面 dispose 为 no-op。
adapter?.dispose();
adapter = null;
}
return {

View File

@ -78,6 +78,73 @@ describe("S4 WebPlatformAdapter", () => {
]);
});
it("defaults to canvas2d renderer and reports its kind without diagnostics", () => {
const env = createDomEnvironment();
const adapter = new WebPlatformAdapter({
canvasElement: env.canvas as unknown as WebPlatformAdapterOptions["canvasElement"],
windowTarget: env.windowTarget as unknown as WebPlatformAdapterOptions["windowTarget"],
storage: env.storage,
fetcher: async () => ({ ok: true, status: 200, json: async () => ({ ok: true }) }),
telemetry: createTelemetryBuffer({ gameVersionId: "version-web-adapter-default-kind", sender: async () => undefined })
});
// 默认偏好必须仍是 canvas2d且不产生回退诊断。
expect(adapter.getRendererKind()).toBe("canvas2d");
expect(adapter.getRendererDiagnostics()).toEqual([]);
});
it("opts into webgl when rendererPreference is webgl and a webgl context exists", () => {
const env = createDomEnvironment({ withWebgl: true });
const adapter = new WebPlatformAdapter({
canvasElement: env.canvas as unknown as WebPlatformAdapterOptions["canvasElement"],
windowTarget: env.windowTarget as unknown as WebPlatformAdapterOptions["windowTarget"],
storage: env.storage,
fetcher: async () => ({ ok: true, status: 200, json: async () => ({ ok: true }) }),
telemetry: createTelemetryBuffer({ gameVersionId: "version-web-adapter-webgl", sender: async () => undefined }),
rendererPreference: "webgl"
});
const sdk = adapter.createSdk();
const handle = sdk.Canvas.createCanvas(320, 180);
sdk.Canvas.submitRenderCommands(handle, [{ type: "rect", x: 1, y: 2, width: 3, height: 4, fill: "#ff0000" }]);
expect(adapter.getRendererKind()).toBe("webgl");
// WebGL 实际产出绘制调用(非空输出),证明渲染面在工作。
expect(env.glContext.calls.some((call) => call[0] === "drawArrays")).toBe(true);
});
it("falls back to canvas2d with a diagnostic when webgl is requested but unavailable", () => {
const env = createDomEnvironment();
const adapter = new WebPlatformAdapter({
canvasElement: env.canvas as unknown as WebPlatformAdapterOptions["canvasElement"],
windowTarget: env.windowTarget as unknown as WebPlatformAdapterOptions["windowTarget"],
storage: env.storage,
fetcher: async () => ({ ok: true, status: 200, json: async () => ({ ok: true }) }),
telemetry: createTelemetryBuffer({ gameVersionId: "version-web-adapter-webgl-fallback", sender: async () => undefined }),
rendererPreference: "webgl"
});
// webgl 上下文为 null回退 canvas2d 并带回退诊断码。
expect(adapter.getRendererKind()).toBe("canvas2d");
expect(adapter.getRendererDiagnostics()).toContain("WEBGL_UNAVAILABLE_FALLBACK_CANVAS2D");
});
it("falls back to canvas2d with WEBGL_INIT_FAILED diagnostic when webgl context exists but shader init fails", () => {
// 提供一个能返回 GL 上下文、但着色器编译会失败的 canvasWebGlRenderSurface 构造抛错。
const env = createDomEnvironment({ withWebgl: true, glFailCompile: true });
const adapter = new WebPlatformAdapter({
canvasElement: env.canvas as unknown as WebPlatformAdapterOptions["canvasElement"],
windowTarget: env.windowTarget as unknown as WebPlatformAdapterOptions["windowTarget"],
storage: env.storage,
fetcher: async () => ({ ok: true, status: 200, json: async () => ({ ok: true }) }),
telemetry: createTelemetryBuffer({ gameVersionId: "version-web-adapter-webgl-init-failed", sender: async () => undefined }),
rendererPreference: "webgl"
});
// WebGL 初始化失败必须安全回退到 canvas2d并记录 WEBGL_INIT_FAILED_FALLBACK_CANVAS2D 诊断。
expect(adapter.getRendererKind()).toBe("canvas2d");
expect(adapter.getRendererDiagnostics()).toContain("WEBGL_INIT_FAILED_FALLBACK_CANVAS2D");
});
it("uses endpoint ids for network and retries failed requests", async () => {
const env = createDomEnvironment();
const requested: string[] = [];
@ -208,7 +275,7 @@ describe("S4 WebPlatformAdapter", () => {
});
});
function createDomEnvironment() {
function createDomEnvironment(options: { readonly withWebgl?: boolean; readonly glFailCompile?: boolean; readonly glFailLink?: boolean } = {}) {
const context = {
calls: [] as unknown[][],
fillStyle: "",
@ -218,6 +285,12 @@ function createDomEnvironment() {
fillText: vi.fn((...args: unknown[]) => context.calls.push(["fillText", ...args])),
drawImage: vi.fn((...args: unknown[]) => context.calls.push(["drawImage", ...args]))
};
// 仅当用例显式请求时才提供 mock WebGL 上下文,保证默认路径仍只暴露 2D。
// glFailCompile/glFailLink 让该 GL 在着色器编译/链接阶段失败,用于回退集成测试。
const glContext = createMockGlContext({
...(options.glFailCompile === undefined ? {} : { failCompile: options.glFailCompile }),
...(options.glFailLink === undefined ? {} : { failLink: options.glFailLink })
});
const listeners = new Map<string, Set<(event: Record<string, unknown>) => void>>();
const windowListeners = new Map<string, Set<(event: Record<string, unknown>) => void>>();
const createTarget = (listenerMap: Map<string, Set<(event: Record<string, unknown>) => void>>) => ({
@ -239,7 +312,12 @@ function createDomEnvironment() {
clientWidth: 320,
clientHeight: 180,
getBoundingClientRect: () => ({ left: 10, top: 20, width: 320, height: 180 }),
getContext: (type: string) => (type === "2d" ? context : null),
getContext: (type: string) => {
if (type === "2d") return context;
// 仅在请求 withWebgl 时为 webgl/webgl2 返回 mock GL 上下文。
if (options.withWebgl === true && (type === "webgl" || type === "webgl2")) return glContext;
return null;
},
...createTarget(listeners)
};
const storageValues = new Map<string, string>();
@ -265,7 +343,81 @@ function createDomEnvironment() {
return {
canvas,
context,
glContext,
storage,
windowTarget
};
}
// 最小 mock WebGL 上下文:记录调用,构造/绘制路径都返回有效句柄,使 WebGlRenderSurface 成功初始化。
// 通过 options 可让着色器编译/程序链接失败,用于断言 WebGL 初始化失败时回退 Canvas2D 的集成路径。
function createMockGlContext(options: { readonly failCompile?: boolean; readonly failLink?: boolean } = {}) {
const calls: unknown[][] = [];
const record = (name: string, ...args: unknown[]): void => {
calls.push([name, ...args]);
};
const handle = (): object => ({});
return {
calls,
COLOR_BUFFER_BIT: 0x4000,
VERTEX_SHADER: 0x8b31,
FRAGMENT_SHADER: 0x8b30,
COMPILE_STATUS: 0x8b81,
LINK_STATUS: 0x8b82,
ARRAY_BUFFER: 0x8892,
STATIC_DRAW: 0x88e4,
DYNAMIC_DRAW: 0x88e8,
FLOAT: 0x1406,
TRIANGLES: 0x0004,
TRIANGLE_STRIP: 0x0005,
TEXTURE_2D: 0x0de1,
TEXTURE0: 0x84c0,
RGBA: 0x1908,
UNSIGNED_BYTE: 0x1401,
TEXTURE_MIN_FILTER: 0x2801,
TEXTURE_MAG_FILTER: 0x2800,
TEXTURE_WRAP_S: 0x2802,
TEXTURE_WRAP_T: 0x2803,
LINEAR: 0x2601,
CLAMP_TO_EDGE: 0x812f,
BLEND: 0x0be2,
SRC_ALPHA: 0x0302,
ONE_MINUS_SRC_ALPHA: 0x0303,
viewport: (...a: unknown[]) => record("viewport", ...a),
clearColor: (...a: unknown[]) => record("clearColor", ...a),
clear: (...a: unknown[]) => record("clear", ...a),
createShader: () => handle(),
shaderSource: () => undefined,
compileShader: () => undefined,
getShaderParameter: () => options.failCompile !== true,
getShaderInfoLog: () => null,
deleteShader: () => undefined,
createProgram: () => handle(),
attachShader: () => undefined,
detachShader: () => undefined,
linkProgram: () => undefined,
getProgramParameter: () => options.failLink !== true,
getProgramInfoLog: () => null,
useProgram: (...a: unknown[]) => record("useProgram", ...a),
deleteProgram: () => undefined,
getAttribLocation: () => 0,
getUniformLocation: () => handle(),
createBuffer: () => handle(),
bindBuffer: () => undefined,
bufferData: () => undefined,
deleteBuffer: () => undefined,
enableVertexAttribArray: () => undefined,
vertexAttribPointer: () => undefined,
uniform4f: (...a: unknown[]) => record("uniform4f", ...a),
uniform1i: () => undefined,
enable: () => undefined,
blendFunc: () => undefined,
createTexture: () => handle(),
bindTexture: () => undefined,
deleteTexture: () => undefined,
activeTexture: () => undefined,
texParameteri: () => undefined,
texImage2D: (...a: unknown[]) => record("texImage2D", ...a),
drawArrays: (...a: unknown[]) => record("drawArrays", ...a)
};
}

View File

@ -1,5 +1,7 @@
import { createRuntimeSdk } from "../../../../../packages/runtime-sdk/src/index";
import type { TelemetryBuffer } from "../../../../../packages/runtime-sdk/src/index";
import { createRenderSurface } from "../../../../../packages/web-runtime/src/createRenderSurface";
import type { Ctx2dLike, GLLike, RenderSurface, TextLayerLike } from "../../../../../packages/web-runtime/src/RenderSurface";
import type {
AudioHandleId,
AudioLoadRequestDto,
@ -12,17 +14,38 @@ import type {
RuntimeSdkContract
} from "../../../../../packages/shared-contracts/src/runtime-sdk-contract";
// 渲染器偏好:默认 canvas2dwebgl 为显式 opt-inpreference flag失败自动回退 canvas2d。
type RendererPreference = "canvas2d" | "webgl";
// 结构化的 WebGL 上下文类型:与 RenderSurface 的 GLLike 对齐,避免依赖浏览器 lib 类型。
type WebGlContextLike = GLLike;
// 在既有 WebCanvasElement 上以结构方式扩展 getContext 重载,仅声明本适配器用到的子集:
// - "2d" 仍返回 CanvasRenderingContext2D保持既有 mock 兼容)。
// - "webgl"/"webgl2" 返回结构化 GL 上下文opt-in 时使用)。
type WebCanvasElement = {
width: number;
height: number;
clientWidth: number;
clientHeight: number;
getBoundingClientRect: () => { left: number; top: number; width: number; height: number };
getContext: (type: "2d") => CanvasRenderingContext2D | null;
getContext: ((type: "2d") => CanvasRenderingContext2D | null) &
((type: "webgl") => WebGlContextLike | null) &
((type: "webgl2") => WebGlContextLike | null);
addEventListener: (type: string, handler: (event: Event) => void) => void;
removeEventListener: (type: string, handler: (event: Event) => void) => void;
};
// 注入的离屏画布工厂:优先 OffscreenCanvas浏览器若不支持则用 documentTarget 创建元素画布。
// 该工厂保持可注入,便于测试与无 DOM 环境降级。
type WebDocumentTarget = {
createElement: (tagName: "canvas") => {
width: number;
height: number;
getContext: (type: "2d") => (TextLayerLike["context2d"] & Ctx2dLike) | null;
};
};
type WebWindowTarget = {
readonly devicePixelRatio?: number;
readonly setTimeout?: (handler: () => void, timeout: number) => ReturnType<typeof setTimeout>;
@ -54,19 +77,74 @@ export type WebPlatformAdapterOptions = {
readonly fetcher: WebFetcher;
readonly endpoints?: Record<string, string>;
readonly retryDelayMs?: number;
// 渲染器偏好opt-in缺省 undefined => "canvas2d",保持既有默认行为不变。
readonly rendererPreference?: RendererPreference;
// 可选注入的 document用于在缺少 OffscreenCanvas 时创建文本图层离屏画布。
readonly documentTarget?: WebDocumentTarget;
};
export class WebPlatformAdapter {
private readonly canvasHandles = new Set<string>();
private readonly audioHandles = new Set<string>();
private readonly context: CanvasRenderingContext2D;
// 内部渲染面clear/rect/text 全部经由它落地,逻辑模块永远拿不到 GL/2D 句柄。
private readonly surface: RenderSurface;
// 渲染诊断回退原因、WebGL 文本图层缺失等),供 UI/冒烟观测。
private readonly rendererDiagnostics: string[] = [];
private nextCanvasId = 1;
private nextAudioId = 1;
constructor(private readonly options: WebPlatformAdapterOptions) {
const context = options.canvasElement.getContext("2d");
if (context === null) throw new Error("WEB_PLATFORM_CANVAS_2D_UNAVAILABLE");
this.context = context;
const preference: RendererPreference = options.rendererPreference ?? "canvas2d";
// 通过 createRenderSurface 选择渲染面:真实 DOM 访问全部以函数形式注入,渲染面本身保持 DOM-free。
// 诊断统一经 onDiagnostic 回调收集(工厂每条诊断都会触发一次),避免与返回数组重复计数。
const { surface } = createRenderSurface({
preference,
getSize: () => ({ width: options.canvasElement.width, height: options.canvasElement.height }),
// WebGL 上下文:优先 webgl2回退 webgl不可用返回 null。
tryWebgl: () => options.canvasElement.getContext("webgl2") ?? options.canvasElement.getContext("webgl"),
// 2D 上下文:作为默认与回退渲染面;类型在结构上与 Ctx2dLike 兼容。
get2d: () => options.canvasElement.getContext("2d") as unknown as Ctx2dLike | null,
// 文本图层工厂WebGL 路径栅格化文字时使用;无可用离屏画布时返回 nullWebGL 会跳过文本并发诊断)。
createTextLayer: (width, height) => this.createTextLayer(width, height),
onDiagnostic: (code) => this.rendererDiagnostics.push(code)
});
this.surface = surface;
}
// getRendererKind暴露本次实际生效的渲染器类型便于 UI/冒烟断言(含回退后的结果)。
getRendererKind(): RendererPreference {
return this.surface.kind;
}
// getRendererDiagnostics暴露渲染诊断快照回退原因等返回副本避免外部篡改。
getRendererDiagnostics(): string[] {
return [...this.rendererDiagnostics];
}
// dispose宿主停止运行时调用释放渲染面持有的底层资源WebGL 程序/缓冲/纹理)。
// 委托给渲染面的可选 dispose 钩子Canvas2D 无需释放(钩子缺省即 no-op
dispose(): void {
this.surface.dispose?.();
}
// createTextLayer构造离屏 2D 图层供 WebGL 文本栅格化。
// 优先 OffscreenCanvas否则用注入的 documentTarget 创建元素画布;都不可用则返回 null。
private createTextLayer(width: number, height: number): TextLayerLike | null {
if (typeof OffscreenCanvas !== "undefined") {
const offscreen = new OffscreenCanvas(width, height);
const context2d = offscreen.getContext("2d");
if (context2d === null) return null;
return { width, height, context2d: context2d as unknown as TextLayerLike["context2d"] };
}
if (this.options.documentTarget !== undefined) {
const element = this.options.documentTarget.createElement("canvas");
element.width = width;
element.height = height;
const context2d = element.getContext("2d");
if (context2d === null) return null;
return { width, height, context2d };
}
return null;
}
createSdk(): RuntimeSdkContract {
@ -115,34 +193,18 @@ export class WebPlatformAdapter {
const handle = { handleId: `web-canvas-${this.nextCanvasId}` };
this.nextCanvasId += 1;
this.canvasHandles.add(handle.handleId);
// 仍然设置宿主画布的像素尺寸(兼容既有行为),再通知渲染面更新视口。
this.options.canvasElement.width = width;
this.options.canvasElement.height = height;
this.surface.resize(width, height);
return handle;
}
private submitRenderCommands(handle: CanvasHandle, commands: readonly RenderCommand[]): void {
// 保留既有句柄守卫:未知句柄一律拒绝,避免逻辑模块伪造句柄绘制。
if (!this.canvasHandles.has(handle.handleId)) throw new Error("WEB_PLATFORM_CANVAS_HANDLE_UNKNOWN");
for (const command of commands) {
if (command.type === "clear") {
this.context.clearRect(0, 0, this.options.canvasElement.width, this.options.canvasElement.height);
if (command.color !== undefined) {
this.context.fillStyle = command.color;
this.context.fillRect(0, 0, this.options.canvasElement.width, this.options.canvasElement.height);
}
continue;
}
if (command.type === "rect") {
this.context.fillStyle = command.fill ?? "#ffffff";
this.context.fillRect(command.x, command.y, command.width, command.height);
continue;
}
if (command.type === "text") {
this.context.fillStyle = command.fill ?? "#ffffff";
this.context.font = `${command.fontSize ?? 12}px sans-serif`;
this.context.fillText(command.text, command.x, command.y);
}
}
// 渲染落地委托给内部渲染面canvas2d 或 webgl逻辑模块只产出平台中立命令。
this.surface.submit(commands);
}
private onCanvasPointer(type: string, sdkType: InputEventDto["type"], handler: (event: InputEventDto) => void): () => void {

View File

@ -0,0 +1,318 @@
# MVP 剩余清单与路线
> 生成时间2026-06-05
> 状态快照:基于 git HEAD `98e2aad`S0-S5 已合并)及当前 session Web/WebGL playable demo slice。
> 受众:团队 + 未来 agent。
> 证据原则:本文只引用已有验证记录;未经验证的状态一律标注 not_started 或待确认。
---
## 1. 结论
**当前可展示切片**
Web playable demo slice路由 `/preview/simulation-fixture-v1`Canvas2D 默认渲染 + opt-in WebGL fallback。该切片是本 session 正在推进的工作,基于 S4 runtime smoke PASS 与 S5 mini-game static gate PASS 的基础上交付。
**MVP 整体现状**
| 阶段 | 状态 | 关键证据 |
|------|------|---------|
| S0 harness | 完成 | git `6e363ae` 起多个 feat/harness 提交,门禁 PASS |
| S1 app foundation | 完成 | git `bba5e13`Task9 scope gate PASS |
| S2 creator workbench | 完成 | git `8ffeba9`-`b4c17c8` 系列提交 |
| S3 simulation slice | 完成 | git `e81e433`-`76d2444`scope gate PASS |
| S4 web runtime | 完成 | runtime smoke PASS23 files / 244 tests`2026-06-05-S4Task5运行冒烟.md` |
| S5 mini-game conversion | static_validated 仅import cert = No-Go | 三端 static gate PASSDevTool import smoke 默认写 blocker无 passed evidence |
| S6 publish-review-feed | **not_started** | — |
| S7 feedback/telemetry | **not_started** | — |
| S8 deploy-readiness | **not_started** | — |
| Web/WebGL playable demo | 本 session 进行中 | — |
**WeChat/Douyin DevTools import certification = 硬 No-Go 门**
`DevToolImportEvidence.result="passed"` 目前不存在。`docs/evidence/devtool-import/` 目录内仅有 `README.md`,无任何 passed evidence 文件。这是 S5 import certification 和 S8 Go/No-Go 的前置硬门禁,不可绕过。
---
## 2. 已完成S0S5 + Web playable demo slice
### S0S3harness + foundation + creator workbench + simulation
- git 历史从 `ba058fb`(平台路线图)→ `e90bf2d`S3 phase-aware scope gate`76d2444`S3 runtime backend presearch完整覆盖。
- S0 harness 门禁通过lifecycle validation baseline 已建立(`6e363ae`)。
- S1 app foundation 完成Task9 scope gate 通过(`9294101`)。
- S2 creator workbench 合同、API、UI、IR compiler、阶段 scope gate 均完成。
- S3 simulation slice contracts、compiler、logic module、validator、consumer contract 均完成scope gate PASS。
### S4Web runtime
- 任务链scope gate → game package contract → web package builder → runtime SDK → preview runtime UI → runtime smoke。
- 全部 fresh spec review + quality review PASS详见 `docs/memorys/2026-06-05-S4Task*.md`
- runtime smoke`runRuntimeSmokeWithPlaywright` PASS244 tests`pnpm typecheck`/`lint` PASS。
- **S4 = Web runtime green基础已具备 Web playable demo slice 推进条件。**
### S5Mini-game conversionstatic_validated only
- 转换合同、平台适配器wechat/douyin/kuaishou、项目生成器、static gate 均完成并 PASS。
- 三端 simulation fixture static gate 结果:`wechat_minigame=static_validated``douyin_minigame=static_validated``kuaishou_minigame=static_validated`(保留 KUAISHOU_GLOBAL_AND_CONFIG_SHAPE_UNKNOWN warning
- `smoke:devtool-import` 默认写 `DevToolImportBlocker`exit 1无法生成 passed evidence。
- S5 Task5 Task6 fresh review 双 PASS**S5 import certification 仍是 No-Go**,详见 `docs/memorys/2026-06-05-S5Task6最终验证.md`
- S4/S5 并行汇合验证 PASSmerge commit `98e2aad`,详见 `docs/memorys/2026-06-05-S4S5并行汇合.md`
### Web/WebGL playable demo slice本 session
- 基于 S4 runtime`/preview/simulation-fixture-v1` 暴露 Canvas2D 默认 + opt-in WebGL fallback。
- 该切片是当前 session 正在推进的工作,状态以实际验证结果为准,本文不超前声称 PASS。
---
## 3. 剩余阶段
### S6站内发布 / 运营审核 / 移动端 Feed
**状态not_started**
**P0 任务清单**
| 任务 ID | 标题 | 文件路径 | 依赖 | 验证类型 |
|---------|------|---------|------|---------|
| S6T1 | Define Contracts | `packages/shared-contracts/src/publish-review-feed.ts` | S5 完成 | unit |
| S6T2 | Publish Gate | `apps/api/src/modules/publish/` | S6T1 | integration-db |
| S6T3 | Review Queue + Operator Actions | `apps/api/src/modules/review/` | S6T2 | integration-db |
| S6T4 | Mobile Feed API + UI | `apps/api/src/modules/feed/` | S6T3 | integration-db |
| S6T5 | Interactions + Share | `apps/api/src/modules/interactions/` | S6T4 | integration-db |
| S6T6 | Final Verify | — | S6T5 | unit |
**并行分组**
- Group A独立S6T1 可独立开始,只需 shared-contracts。
- Group B串行S6T2 → S6T3 → S6T4 → S6T5依赖链不可并行
- Group C汇合S6T6 依赖 S6T1-T5 全部完成。
**验证分级**
- unit 可在本环境运行S6T1合同 schema 测试、S6T6集成最终 gate
- integration-db 必须有真实 PostgresS6T2 / S6T3 / S6T4 / S6T5 的 API 端到端测试。
**S6 硬门禁**
- `ReviewRecord` 状态为 `approved` 才能创建 `PublishedGame`creator 不能自审)。
- `GamePackage.web.status=smoke_passed` 是提交发布的前置硬条件。
- feed 查询结果不得包含 `pending/rejected/unpublished` 状态游戏。
- `MiniGameProject` 绝不能成为 feed 可玩 item。
- creator 不能审核自己提交的版本。
**S6 P1 / 后置(不阻塞 P0**
- runtime 错误自动限制曝光规则。
- ML feed 排序算法。
- 评论、弹幕、粉丝体系。
- 渠道提审、广告、支付、结算。
---
### S7Feedback / Telemetry
**状态not_started**
**P0 任务清单**
| 任务 ID | 标题 | 文件路径 | 依赖 | 验证类型 |
|---------|------|---------|------|---------|
| S7-T1 | Telemetry contracts | `packages/shared-contracts/src/telemetry.ts` | S6 完成 | unit |
| S7-T2 | POST /events/batch ingestion | `apps/api/src/modules/telemetry/` + prisma | S7-T1 | integration-db |
| S7-T3 | Minimal quality signals worker | `apps/api/src/modules/feedback/` | S7-T2 | integration-db |
| S7-T4 | Document deferred dashboards | `docs/future/` | — | manual-gate文档|
| S7-T5 | Channel readiness notes API + UI | `apps/api/src/modules/channel-readiness/``apps/web` operator page | S7-T2 | integration-db |
| S7-T6 | Final verify | — | S7-T3, T5 | unit |
**并行分组**
- Group A可并行{S7-T1, S7-T4}。
- Group B可并行依赖 T1{S7-T2, S7-T5}。
- Group C串行{S7-T3}(依赖 T2
- Group D汇合S7-T6。
**验证分级**
- unitS7-T1schema、S7-T6final gate
- integration-dbS7-T2 / T3 / T5。
- manual-gate静态扫描S7-T4文档存在即满足
**S7 硬门禁**
- prisma migration 必须通过。
- `QualitySignal` 不能修改 `GameVersion` / `PublishedGame` 记录。
- `ChannelReadinessNote` 只能引用 S5 `ConversionReport` / `DevToolImportEvidence` / `DevToolImportBlocker` ID不能自造转换状态不能替代 `ChannelReview`
- P0 不能出现:`Monetization``GameDailyStats``CreatorDailyStats``ImprovementSuggestion`
**S7 P1 / 后置(不阻塞 P0**
- 创作者单游戏基础看板、运营质量/错误/举报视图。
- `GameDailyStats``CreatorDailyStats` 聚合统计。
- 简单规则建议(加载失败高、试玩低、留存低、举报高、互动高)。
- 数据保留 / 冷归档策略。
---
### S8Deploy Readiness / MVP Go-No-Go
**状态not_started**
**P0 任务清单**
| 任务 ID | 标题 | 文件路径 | 依赖 | 验证类型 |
|---------|------|---------|------|---------|
| s8-t1 | 环境配置校验 | `scripts/validate-env.mjs` | — | unit静态校验|
| s8-t2 | 健康检查 | `apps/api` health endpointworker heartbeat`scripts/healthcheck.mjs` | — | unit |
| s8-t3 | demo seed | `scripts/seed-demo.mjs` | integration-db | integration-db |
| s8-t4 | MVP smoke | `scripts/smoke-mvp.mjs` | S0-S7 全部通过 + WeChat+Douyin DevToolImportEvidence.result=passed | browser |
| s8-t5 | 运维文档:监控+回滚 | `docs/operations/` | — | static-scan |
| s8-t6 | 验收 runbook + Go/No-Go | `docs/evidence/mvp-acceptance/` | s8-t1 ~ t5 全通过 | manual-gate |
**并行分组**
- Group A可独立并行{s8-t1, s8-t2, s8-t5}。
- Group B依赖 DB{s8-t3}(依赖 integration-db
- Group C依赖全部前序{s8-t4}browser + DevTools passed evidence 必须就位)。
- Group D汇合{s8-t6}(手动 Go/No-Go 最终门)。
**S8 Go/No-Go 规则(硬性)**
`Go` 条件:同时满足以下全部:
1. `wechat_minigame` `DevToolImportEvidence.result="passed"` 文件存在且 checksum 合法。
2. `douyin_minigame` `DevToolImportEvidence.result="passed"` 文件存在且 checksum 合法。
3. S0-S7 所有阶段证据链接完整。
4. smoke-mvp 脚本 exit 0。
任意一项缺失、blocked、failed 或仅有 `DevToolImportBlocker` => **No-Go不得宣称 MVP ready**
**S8 P1 / 后置(不阻塞 P0**
- 三平台真机 smokewechat/douyin/kuaishou device
- 渠道正式提审 / 审核通过。
- 广告、支付、结算生产配置。
- 大规模压测、多地域容灾、failover 演练。
---
## 4. 硬门禁(不可伪造)
以下门禁无法用任何代码、文档或推断替代,必须由真实工具产出真实证据:
| 门禁名称 | 触发阶段 | 证据要求 | 当前状态 |
|---------|---------|---------|---------|
| WeChat DevToolImportEvidence | S5 import cert / S8 | `docs/evidence/devtool-import/wechat_minigame-*.json``result="passed"`,含真实 tool name/version/log/checksum | **No-Go无文件** |
| Douyin DevToolImportEvidence | S5 import cert / S8 | 同上douyin 平台 | **No-Go无文件** |
| ReviewRecord before PublishedGame | S6 | operator approved ReviewRecord 存在,且 creator != reviewer | not_started |
| smoke_passed before feed/submit | S6 | `GamePackage.web.status=smoke_passed` 写入 DB | not_started |
| feed 不返回 pending/rejected/unpublished | S6 | feed API 测试 + 运行时断言 | not_started |
| MVP smoke exit 0 | S8 | `scripts/smoke-mvp.mjs` 实际运行 exit 0需 browser + DB | not_started |
| Go/No-Go 报告 | S8 | `docs/evidence/mvp-acceptance/` 中明确区分真实跑通 / 静态验证 / 人工证据 / 未进 P0 | not_started |
**关于 S5 DevTools blocker 的说明**`DevToolImportBlocker``result="blocked"/"failed"`)记录的是缺口和风险,解释了为何无法导入,但不能代替 `DevToolImportEvidence.result="passed"`。将 blocker 解读为通过证据属于违规。
---
## 5. P1 / 后置事项(按阶段汇总)
| 阶段 | P1 / 后置项目(不阻塞 P0 验收)|
|------|-------------------------------|
| S6 | ML feed 排序;评论/弹幕/粉丝;渠道提审;广告/支付runtime 错误自动降权策略 |
| S7 | 创作者/运营看板;`GameDailyStats` / `CreatorDailyStats` 聚合;`ImprovementSuggestion` 规则建议;数据归档 |
| S8 | 三端真机 smoke渠道正式上架广告/支付生产配置;多地域容灾;压测 |
---
## 6. 沙箱验证天花板
本环境(`/root/games-development-ai`linuxnode 可用)的验证能力边界:
**可以产出真实证据的命令**
```
pnpm lint
pnpm typecheck
pnpm test
pnpm check:s5-scope / check:s4-scope / check:workspace-scripts
pnpm run build:minigame -- --target all --fixture simulation
node --check <file>
git diff --check
```
**需要真实 Postgres 的命令(本环境抛 PrismaClientKnownRequestError**
- S6-S8 的 API integration 测试。
- prisma migrate / seed 命令。
- `scripts/seed-demo.mjs` 和任何需要 DB 写入的冒烟脚本。
**需要真实浏览器的命令(本环境 flaky**
- `runRuntimeSmokeWithPlaywright`S4 smoke 已有 unit wrapper 替代)。
- `scripts/smoke-mvp.mjs`S8需 browser + DB
**需要真实开发者工具的操作(本环境无法模拟)**
- 微信开发者工具 / 抖音开发者工具导入项目。
- 生成有效的 `DevToolImportEvidence.result="passed"` 文件。
**结论**在本沙箱内lint / typecheck / unit / scope-gate / build:minigame 命令产出的证据是真实可信的。DB integration、live device、devtool import 必须在具备完整环境的真实机器上验证,不能在本沙箱伪造或推断为通过。
---
## 7. 建议推进顺序
### 当前优先:完成 Web/WebGL playable demo slice
本 session 正在推进,与 S6/S7/S8 无依赖冲突,可独立完成。
### S6 启动条件
- S4 runtime smoke PASS已满足
- S5 static gate PASS已满足
- S5 import certDevTools passed evidence**不阻塞 S6 编码**,但阻塞 S6 feed 将 mini-game artifact 用于任何"可玩"声称。
- S6T1contracts可立即开始无 DB 依赖。
### S7 启动条件
- S6 P0 完成S7 下游于 S6 feed 和 interaction 事件)。
- S7-T1 和 S7-T4 可与 S6 后期并行。
### S8 启动条件
- S6 + S7 P0 全部完成。
- **WeChat + Douyin DevToolImportEvidence.result="passed" 必须在 s8-t4 之前就位**,否则 smoke-mvp 强制 exit 1。
### 并行机会
```
当前 session
└── Web/WebGL playable demo slice无阻塞
S6 开始后
├── S6T1 contractsunit立即可做
└── S6T2~T5串行需 DB 环境)
S6 完成后
├── S7-T1unit可与 S6 后期 overlap
├── S7-T4文档可随时做
└── S7-T2~T5需 DB
S7 完成后
├── s8-t1unit可提前
├── s8-t2unit可提前
├── s8-t5文档可随时做
└── s8-t3需 DB
└── s8-t4需 browser + DB + DevTools passed evidence
└── s8-t6手动 Go/No-Go 最终门)
```
### 依赖强制串行点
1. S5 DevTools import cert → s8-t4无法绕过无法提前声称
2. ReviewRecord → PublishedGameS6 内部,运营必须先审核)。
3. S0-S7 证据链 → s8-t6 Go/No-Go所有阶段证据必须完整
---
*本文档基于 git HEAD `98e2aad` 和本 session 状态快照编写。下一个关键更新节点Web/WebGL playable demo slice 完成后,或 S5 DevTools import cert 状态变更时。*

View File

@ -0,0 +1,117 @@
# Web/WebGL Playable 里程碑demo 切片)
## 结论
当前交付一个**可向投资人演示的初始 MVP 切片**AI 生成的模拟经营游戏在浏览器中可玩。
预览地址 `http://localhost:3000/preview/simulation-fixture-v1`
- 默认 **Canvas2D** 渲染S4 既有产线,全绿)。
- 新增**可选 WebGL 内部渲染面**`?renderer=webgl` 显式开启WebGL 不可用/初始化失败时**自动回退 Canvas2D 并产出明确诊断**。
**边界(未完成、未认证、未伪造)**S6/S7/S8 仍 `not_started`;微信/抖音 DevTools 导入认证仍是**硬 No-Go**;本次未声称小游戏导入完成、未声称渠道可发布、未声称 S8 Go、未声称 MVP 最终 Go。详见 [[2026-06-05-MVP剩余清单与路线]]。
## 实现范围(本次)
最小、可选、内部可替换的 WebGL 渲染面,挂在 `RuntimeSdkContract.Canvas.submitRenderCommands` 这一个接缝后面;**不改** `RuntimeSdkContract` / `GameLogicModule` / 游戏逻辑;逻辑模块拿不到 WebGL context 或 raw canvas/2d handle。
新增packages/web-runtime/src/
- `RenderSurface.ts``RenderSurface` 接口kind / resize / submit / dispose+ 注入式的最小结构化 context 类型(保持包内 DOM-free、可单测
- `Canvas2dRenderSurface.ts`(+ `.spec.ts`):把原 adapter 的 2D 绘制逻辑clear/rect/text原样抽出。
- `WebGlRenderSurface.ts`(+ `.spec.ts`)clear→clearColor+clearrect→纯色 quad内联 shader构造时编译一次复用text→注入的离屏 2D text layer 光栅化后 texImage2D 合成;无 text layer→一次性诊断 `WEBGL_TEXT_LAYER_UNAVAILABLE`不抛错画面仍非空。init/compile/link 失败抛 typed error 供 factory 回退。
- `createRenderSurface.ts`(+ `.spec.ts`):按 preference 选面webgl 不可用→`WEBGL_UNAVAILABLE_FALLBACK_CANVAS2D`webgl 初始化失败→`WEBGL_INIT_FAILED_FALLBACK_CANVAS2D`;都不可用→抛 `WEB_PLATFORM_RENDER_SURFACE_UNAVAILABLE`/`WEB_PLATFORM_CANVAS_2D_UNAVAILABLE`
修改:
- `packages/web-runtime/src/index.ts`:导出新模块。
- `apps/web/src/components/runtime/WebPlatformAdapter.ts`:新增 `rendererPreference?`(默认 `canvas2d`);构造时经 factory 注入真实 DOM accessor`submitRenderCommands` 委派给 surface暴露 `getRendererKind()` / `getRendererDiagnostics()`;保留 canvasHandles 守卫。
- `apps/web/src/components/runtime/WebPlatformAdapter.test.ts`**仅增量**——原 8 例不变,新增 3 例webgl 选中 / webgl 为 null 回退)。
- `apps/web/src/components/runtime/WebGameRuntime.tsx`:透传 `rendererPreference`(缺省 `canvas2d`controller 保留 adapter 引用,`stop()` 与 load 失败 catch 均 `adapter?.dispose()` 释放渲染面资源。
- `apps/web/src/app/preview/[versionId]/page.tsx`:改为 **async server 组件**`await` 解包 Next.js 16 的 `params/searchParams``?renderer=webgl` 显式开启(缺省 Canvas2D把纯值 `rendererPreference` 传给 client 组件;`readRendererPreference` 导出供单测。
- 新增 `apps/web/src/components/runtime/PreviewRuntimeClient.tsx`client 包装组件,负责 `getPreviewPackageSource` 与渲染loader/reader 函数只在 client 侧创建,不跨 server/client 边界)。
- 新增 `apps/web/src/app/preview/[versionId]/page.test.tsx``readRendererPreference` opt-in 决策单测。
- `apps/web/src/components/runtime/WebPlatformAdapter.test.ts`:新增 webgl 选中 / 回退 / 初始化失败回退三类 renderer 用例(原有用例不变)。
## 不在本次范围
- 不把 WebGL 设为默认渲染器(避免对既有 live smoke 造成回归)。
- 不实现 S6发布/审核/feed、S7遥测/反馈、S8部署就绪/Go-No-Go
- 不做微信/抖音 DevTools 导入认证(仍 No-Go
- 不做真机 smoke、渠道提审、广告/支付。
## Controller 自测orchestrator 独立复跑,非仅采信 implementer 报告)
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| web-runtime 测试 | `pnpm --filter @huijing/web-runtime test` | ✅ 57 passed含 RenderSurface/WebGl/createRenderSurface 三组新 spec |
| runtime-sdk 测试 | `pnpm --filter @huijing/runtime-sdk test` | ✅ 5 passed |
| web 测试 | `pnpm --filter @huijing/web test` | ✅ 52 passed7 files新增 page.test.tsx + PreviewRuntimeClient 用例 + adapter renderer 用例) |
| 类型检查4 包) | `pnpm --filter web-runtime/runtime-sdk/web/shared-contracts typecheck` | ✅ Done |
| Lint3 包) | `pnpm --filter web-runtime/runtime-sdk/web lint` | ✅ Done仓库级 lint 会 OOM按既定只跑这 3 包) |
| runtime-smoke | `pnpm --filter @huijing/api exec vitest run src/modules/game-package` | ✅ 31 passed |
| S5 范围门禁 | `pnpm check:s5-scope` | ✅ exit 0 |
| S4 范围门禁 | `pnpm check:s4-scope` | ⚠️ exit 1**纯既有基线**91 处违规全部在 S5 自有文件,本次 12 个文件**零命中**(已用精确路径核验)。与 [[2026-06-05-S4S5并行汇合]] 记录的 `EXPECTED_FAIL_ON_S5` 一致。 |
| 空白/冲突 | `git diff --check` | ✅ clean |
| Dev server | `GET /preview/...`(默认 + `?renderer=webgl` | ✅ 均 HTTP 200服务端渲染 runtime shell |
## 实时浏览器验证best-effort
沙箱内 Playwright chromium headless_shell 反复安装失败(`__dirlock` 复现、二进制不留存),**真实像素级实时采集在本沙箱被阻塞**。已用以下证据替代:
- dev server 实测两条路由均 HTTP 200且改为 server 组件后 **SSR 输出已包含 `data-runtime-canvas="web-preview"` 与 `Version simulation-fixture-v1`**(修复 opt-in 前的 client 页不会 SSR 出 canvas——证明 `?renderer=webgl` opt-in 路由在服务端正确解包并渲染。
- WebGL 路径的「非空drawArrays/clearColor/ 可交互changed commands→changed draw state/ 回退 + 诊断」由 `WebGlRenderSurface.spec``createRenderSurface.spec``WebPlatformAdapter.test` 单测覆盖。
- runtime-smoke 的 Playwright driver 本身有单测。
投资人演示在用户本机 `next dev` 上进行(`http://localhost:3000/preview/simulation-fixture-v1`,加 `?renderer=webgl` 切 WebGL像素级实时校验留待真实环境与本次约定的 done-bar「static+unit green here」一致未伪造
## Fresh review两轮实现 + controller 自测后spec 与 quality 并发)
> 说明:原计划要求 gpt-5.5 implementer/reviewer当前 Agent 工具仅提供 Claude 模型,用户已确认改用 Claude 模型Opus implementer / Sonnet reviewers。每轮 spec/quality 均为全新子代理并发,不复用旧子代理。
**Round 1**
| 类型 | 子代理标签(模型) | 结论 | 关键结论 |
| --- | --- | --- | --- |
| spec review | `spec-review`sonnet | PASS | 合同/逻辑未改、Canvas2D 默认未变、无边界泄漏、诊断码齐全且有测试。 |
| quality review | `quality-review`sonnet | **FAIL** | 1 个 critical`page.tsx``"use client"` 却从 props 读 `searchParams`Next.js App Router 只把它给 server 组件 → `?renderer=webgl` opt-in 实际失效;另含 clear-alpha 不一致、着色器未释放、`dispose()` 未接线、死分支等 important。 |
**Fix round按规则合并所有 finding 一次性修复)**
fix-round 子代理在 18:17 被用户中断slash 命令打断),已完成 F2/F3/F4/F6/F7全绿。剩余 **F1critical opt-in****F5dispose 接线)** 由 orchestrator 直接补完:
- F1查 Next.js 16 官方文档确认 `params/searchParams` 为 Promiseclient 页需用 `use()`/server 页 `await`。采用 **server 组件页 + 新增 client 包装组件 `PreviewRuntimeClient.tsx`** 的稳健方案;`page.tsx` 改为 async server 组件 `await` 解包并把纯值 `rendererPreference` 传入 client 组件。dev server 实测两条路由均 200 且 SSR 出 canvas。
- F5`WebGameRuntime` controller `stop()`(及 load 失败 catch`adapter?.dispose()` 接线。
**Round 2重新全新并发 review**
| 类型 | 子代理标签(模型) | 结论 | 关键结论 |
| --- | --- | --- | --- |
| spec review | round-2 `spec-review`sonnet | PASS | F1F7 全部确认解决,无回归;唯一 nit 为既有 s4-scope 基线与 apps/api Prisma均与本次无关。 |
| quality review | round-2 `quality-review`sonnet | PASS | F1F7 全部解决1 个 minorload 失败后 adapter 在下次 load/卸载前短暂滞留——已按 reviewer 建议在 catch 中补 `adapter?.dispose()` 并复跑测试通过micro-fix 不再单独 review。 |
双 PASS满足提交门禁。
## DevTools 导入后置边界
DevTools 导入认证后置,不阻塞本次 Web/WebGL playable但**最终仍必须**有微信 + 抖音生成项目的 `DevToolImportEvidence.result="passed"` 文件,才能解除 S5 import certification、S8 Go/No-Go、MVP 最终验收。本次未生成任何 passed evidence`docs/evidence/devtool-import/` 未新增伪造文件。
## git status提交前
```
M apps/web/src/app/preview/[versionId]/page.tsx
M apps/web/src/components/runtime/WebGameRuntime.tsx
M apps/web/src/components/runtime/WebPlatformAdapter.test.ts
M apps/web/src/components/runtime/WebPlatformAdapter.ts
M packages/web-runtime/src/index.ts
?? apps/web/src/app/preview/[versionId]/page.test.tsx
?? apps/web/src/components/runtime/PreviewRuntimeClient.tsx
?? docs/memorys/2026-06-05-MVP剩余清单与路线.md
?? docs/memorys/2026-06-05-WebPlayable里程碑.md
?? packages/web-runtime/src/Canvas2dRenderSurface.spec.ts
?? packages/web-runtime/src/Canvas2dRenderSurface.ts
?? packages/web-runtime/src/RenderSurface.ts
?? packages/web-runtime/src/WebGlRenderSurface.spec.ts
?? packages/web-runtime/src/WebGlRenderSurface.ts
?? packages/web-runtime/src/createRenderSurface.spec.ts
?? packages/web-runtime/src/createRenderSurface.ts
```
`next-env.d.ts` 由 Next 工具自动改写,不纳入提交。)

View File

@ -0,0 +1,70 @@
import { describe, expect, it, vi } from "vitest";
import { Canvas2dRenderSurface } from "./Canvas2dRenderSurface";
import type { Ctx2dLike } from "./RenderSurface";
describe("Canvas2dRenderSurface", () => {
it("paints clear/rect/text with the exact same 2D calls the old adapter issued", () => {
const env = createCtx();
const surface = new Canvas2dRenderSurface(env.ctx, () => ({ width: 320, height: 180 }));
expect(surface.kind).toBe("canvas2d");
surface.submit([
{ type: "clear", color: "#000000" },
{ type: "rect", x: 1, y: 2, width: 3, height: 4, fill: "#ffffff" },
{ type: "text", text: "Coins 1", x: 8, y: 16, fill: "#ffffff", fontSize: 12 }
]);
expect(env.calls).toEqual([
["clearRect", 0, 0, 320, 180],
["fillRect", 0, 0, 320, 180],
["fillRect", 1, 2, 3, 4],
["fillText", "Coins 1", 8, 16]
]);
});
it("clear without color only clears and does not fill", () => {
const env = createCtx();
const surface = new Canvas2dRenderSurface(env.ctx, () => ({ width: 100, height: 50 }));
surface.submit([{ type: "clear" }]);
expect(env.calls).toEqual([["clearRect", 0, 0, 100, 50]]);
});
it("uses default fill and font when omitted, matching old adapter fallbacks", () => {
const env = createCtx();
const surface = new Canvas2dRenderSurface(env.ctx, () => ({ width: 10, height: 10 }));
surface.submit([
{ type: "rect", x: 0, y: 0, width: 2, height: 2 },
{ type: "text", text: "hi", x: 1, y: 1 }
]);
// 默认填充色 #ffffff、默认字号 12px sans-serif 必须与旧 WebPlatformAdapter 保持一致。
expect(env.ctx.fillStyle).toBe("#ffffff");
expect(env.ctx.font).toBe("12px sans-serif");
});
it("resize is a no-op for canvas2d (size comes from accessor)", () => {
const env = createCtx();
const surface = new Canvas2dRenderSurface(env.ctx, () => ({ width: 4, height: 4 }));
expect(() => surface.resize(800, 600)).not.toThrow();
surface.submit([{ type: "clear", color: "#111111" }]);
expect(env.calls).toEqual([
["clearRect", 0, 0, 4, 4],
["fillRect", 0, 0, 4, 4]
]);
});
});
function createCtx() {
const calls: unknown[][] = [];
const ctx: Ctx2dLike = {
fillStyle: "",
font: "",
clearRect: vi.fn((...args: number[]) => calls.push(["clearRect", ...args])),
fillRect: vi.fn((...args: number[]) => calls.push(["fillRect", ...args])),
fillText: vi.fn((text: string, x: number, y: number) => calls.push(["fillText", text, x, y]))
};
return { calls, ctx };
}

View File

@ -0,0 +1,49 @@
import type { RenderCommand } from "../../shared-contracts/src/runtime-sdk-contract";
import type { Ctx2dLike, RenderSurface } from "./RenderSurface";
// 当前画布尺寸读取器clear 需要用到画布全幅尺寸尺寸由宿主侧canvasElement持有注入读取。
export type CanvasSizeAccessor = () => { readonly width: number; readonly height: number };
// Canvas2dRenderSurface 是默认渲染面,逐条把 RenderCommand 落到 2D 上下文。
// painting 逻辑从旧 WebPlatformAdapter.submitRenderCommands 原样迁移过来,保持像素级行为一致。
export class Canvas2dRenderSurface implements RenderSurface {
readonly kind = "canvas2d" as const;
constructor(
private readonly context: Ctx2dLike,
private readonly getSize: CanvasSizeAccessor
) {}
// 2D 画布尺寸由 canvasElement.width/height 决定,这里无需额外操作(保留以满足接口)。
resize(): void {
// no-opCanvas2D 的视口直接来自宿主画布尺寸访问器,无独立投影状态需要更新。
}
submit(commands: readonly RenderCommand[]): void {
const size = this.getSize();
for (const command of commands) {
if (command.type === "clear") {
// clear先清空全幅再在有指定背景色时铺一层底色与旧适配器一致
this.context.clearRect(0, 0, size.width, size.height);
if (command.color !== undefined) {
this.context.fillStyle = command.color;
this.context.fillRect(0, 0, size.width, size.height);
}
continue;
}
if (command.type === "rect") {
// rect默认填充色 #ffffff与旧适配器保持一致。
this.context.fillStyle = command.fill ?? "#ffffff";
this.context.fillRect(command.x, command.y, command.width, command.height);
continue;
}
if (command.type === "text") {
// text默认字号 12px sans-serif、默认填充 #ffffff与旧适配器保持一致。
this.context.fillStyle = command.fill ?? "#ffffff";
this.context.font = `${command.fontSize ?? 12}px sans-serif`;
this.context.fillText(command.text, command.x, command.y);
}
// 注意sprite 类型在旧适配器中也未实现,这里保持一致地忽略,避免引入新行为。
}
}
}

View File

@ -0,0 +1,131 @@
import type { RenderCommand } from "../../shared-contracts/src/runtime-sdk-contract";
// RenderSurface 是 submitRenderCommands 背后的内部渲染面抽象。
// 逻辑模块永远只产出 RenderCommand[],宿主在这里把命令落到具体的 Canvas2D 或 WebGL 后端。
// 该模块保持 DOM-free只依赖注入进来的结构类型方便在 Node 下做单元测试。
export interface RenderSurface {
// kind 让宿主/UI 能观测到本帧实际跑的是哪种渲染器(用于回退诊断与冒烟断言)。
readonly kind: "canvas2d" | "webgl";
// resize 在 createCanvas 时被调用,单位是像素;具体后端据此更新视口/投影。
resize(width: number, height: number): void;
// submit 把一帧的渲染命令落到后端,必须与旧 Canvas2D 行为等价。
submit(commands: readonly RenderCommand[]): void;
// dispose 是可选的资源释放钩子WebGL 着色器/纹理等)。
dispose?(): void;
}
// Canvas2D 渲染面只需要的最小结构子集;注入即可,避免依赖浏览器 DOM 类型。
export interface Ctx2dLike {
fillStyle: string;
font: string;
clearRect(x: number, y: number, width: number, height: number): void;
fillRect(x: number, y: number, width: number, height: number): void;
fillText(text: string, x: number, y: number): void;
}
// 文本图层WebGL 后端用一块离屏 2D 画布栅格化文字,再作为纹理合成。
// width/height 是该离屏画布尺寸context2d 用来绘制并读取像素。
export interface TextLayerLike {
readonly width: number;
readonly height: number;
readonly context2d: TextLayerContext2dLike;
}
// 文本图层 2D 上下文需要的最小子集:清屏、写字、读像素。
export interface TextLayerContext2dLike {
fillStyle: string;
font: string;
clearRect(x: number, y: number, width: number, height: number): void;
fillText(text: string, x: number, y: number): void;
getImageData(x: number, y: number, width: number, height: number): { readonly data: ArrayLike<number>; readonly width: number; readonly height: number };
}
// WebGL 渲染面需要的最小 GL 方法/常量子集raw WebGL无第三方引擎
// 只声明实现真正用到的成员,保持结构类型最小且可被 mock 替身实现。
export interface GLLike {
readonly COLOR_BUFFER_BIT: number;
readonly VERTEX_SHADER: number;
readonly FRAGMENT_SHADER: number;
readonly COMPILE_STATUS: number;
readonly LINK_STATUS: number;
readonly ARRAY_BUFFER: number;
readonly STATIC_DRAW: number;
readonly DYNAMIC_DRAW: number;
readonly FLOAT: number;
readonly TRIANGLES: number;
readonly TRIANGLE_STRIP: number;
readonly TEXTURE_2D: number;
readonly TEXTURE0: number;
readonly RGBA: number;
readonly UNSIGNED_BYTE: number;
readonly TEXTURE_MIN_FILTER: number;
readonly TEXTURE_MAG_FILTER: number;
readonly TEXTURE_WRAP_S: number;
readonly TEXTURE_WRAP_T: number;
readonly LINEAR: number;
readonly CLAMP_TO_EDGE: number;
readonly BLEND: number;
readonly SRC_ALPHA: number;
readonly ONE_MINUS_SRC_ALPHA: number;
viewport(x: number, y: number, width: number, height: number): void;
clearColor(red: number, green: number, blue: number, alpha: number): void;
clear(mask: number): void;
createShader(type: number): WebGlObject | null;
shaderSource(shader: WebGlObject, source: string): void;
compileShader(shader: WebGlObject): void;
getShaderParameter(shader: WebGlObject, pname: number): unknown;
getShaderInfoLog(shader: WebGlObject): string | null;
deleteShader(shader: WebGlObject): void;
createProgram(): WebGlObject | null;
attachShader(program: WebGlObject, shader: WebGlObject): void;
detachShader(program: WebGlObject, shader: WebGlObject): void;
linkProgram(program: WebGlObject): void;
getProgramParameter(program: WebGlObject, pname: number): unknown;
getProgramInfoLog(program: WebGlObject): string | null;
useProgram(program: WebGlObject | null): void;
deleteProgram(program: WebGlObject): void;
getAttribLocation(program: WebGlObject, name: string): number;
getUniformLocation(program: WebGlObject, name: string): WebGlObject | null;
createBuffer(): WebGlObject | null;
bindBuffer(target: number, buffer: WebGlObject | null): void;
bufferData(target: number, data: ArrayBufferView, usage: number): void;
deleteBuffer(buffer: WebGlObject): void;
enableVertexAttribArray(index: number): void;
vertexAttribPointer(index: number, size: number, type: number, normalized: boolean, stride: number, offset: number): void;
uniform4f(location: WebGlObject | null, x: number, y: number, z: number, w: number): void;
uniform1i(location: WebGlObject | null, x: number): void;
enable(cap: number): void;
blendFunc(sfactor: number, dfactor: number): void;
createTexture(): WebGlObject | null;
bindTexture(target: number, texture: WebGlObject | null): void;
deleteTexture(texture: WebGlObject): void;
activeTexture(texture: number): void;
texParameteri(target: number, pname: number, param: number): void;
texImage2D(target: number, level: number, internalformat: number, width: number, height: number, border: number, format: number, type: number, pixels: ArrayBufferView | null): void;
drawArrays(mode: number, first: number, count: number): void;
}
// WebGL 句柄对象的最小别名mock 替身可用任意非空对象冒充。
export type WebGlObject = object;
// WebGL 初始化/编译/链接失败时抛出的类型化错误,供工厂识别并回退 Canvas2D。
export class WebGlRenderSurfaceInitError extends Error {
// reasonCode 让上层用稳定字符串而非消息文本判断错误类型。
readonly reasonCode: string;
constructor(reasonCode: string, detail?: string) {
super(detail === undefined ? reasonCode : `${reasonCode}:${detail}`);
this.name = "WebGlRenderSurfaceInitError";
this.reasonCode = reasonCode;
}
}

View File

@ -0,0 +1,257 @@
import { describe, expect, it } from "vitest";
import { WebGlRenderSurface } from "./WebGlRenderSurface";
import { WebGlRenderSurfaceInitError } from "./RenderSurface";
import type { GLLike, TextLayerLike, WebGlObject } from "./RenderSurface";
describe("WebGlRenderSurface", () => {
it("clear issues clearColor + clear(COLOR_BUFFER_BIT)", () => {
const gl = createMockGl();
const surface = new WebGlRenderSurface({ gl, getSize: () => ({ width: 320, height: 180 }) });
surface.submit([{ type: "clear", color: "#204060" }]);
// 解析 #204060 -> 归一化 RGBA断言 clearColor 被调用且 clear 用了 COLOR_BUFFER_BIT。
expect(gl.calls).toContainEqual(["clearColor", 0x20 / 255, 0x40 / 255, 0x60 / 255, 1]);
expect(gl.calls).toContainEqual(["clear", gl.COLOR_BUFFER_BIT]);
});
it("clear without a color uses transparent alpha (0) to match Canvas2D clearRect parity", () => {
const gl = createMockGl();
const surface = new WebGlRenderSurface({ gl, getSize: () => ({ width: 320, height: 180 }) });
surface.submit([{ type: "clear" }]);
// 无颜色清屏必须是完全透明alpha=0与 Canvas2D 的 clearRect 透明语义对齐,而非不透明黑。
const clearColorCall = gl.calls.find((call) => call[0] === "clearColor");
expect(clearColorCall).toBeDefined();
expect(clearColorCall?.[4]).toBe(0);
expect(gl.calls).toContainEqual(["clear", gl.COLOR_BUFFER_BIT]);
});
it("compiles programs once at construction and reuses them across submits (non-empty output)", () => {
const gl = createMockGl();
const surface = new WebGlRenderSurface({ gl, getSize: () => ({ width: 100, height: 100 }) });
// 构造时一次性编译着色器程序;记录构造后程序数量基线。
const programsAfterConstruction = gl.programCount;
expect(programsAfterConstruction).toBeGreaterThanOrEqual(1);
surface.submit([
{ type: "rect", x: 0, y: 0, width: 10, height: 10, fill: "#ff0000" },
{ type: "rect", x: 20, y: 20, width: 5, height: 5, fill: "#00ff00" }
]);
surface.submit([{ type: "rect", x: 0, y: 0, width: 1, height: 1, fill: "#0000ff" }]);
// 关键不变量submit 帧绘制不再创建任何新程序,证明程序被复用。
expect(gl.programCount).toBe(programsAfterConstruction);
// rect 通过 drawArrays 产生非空输出。
const draws = gl.calls.filter((call) => call[0] === "drawArrays");
expect(draws.length).toBeGreaterThanOrEqual(3);
});
it("text with a text layer uploads a texture (texImage2D) and draws it", () => {
const gl = createMockGl();
const textLayer = createMockTextLayer(64, 32);
const surface = new WebGlRenderSurface({
gl,
getSize: () => ({ width: 320, height: 180 }),
textLayer
});
surface.submit([{ type: "text", text: "Coins 1", x: 8, y: 16, fill: "#ffffff", fontSize: 12 }]);
expect(gl.calls.some((call) => call[0] === "texImage2D")).toBe(true);
expect(gl.calls.some((call) => call[0] === "drawArrays")).toBe(true);
// 文本图层应被写入文字。
expect(textLayer.context2d.drawnTexts).toContain("Coins 1");
});
it("text without a layer pushes WEBGL_TEXT_LAYER_UNAVAILABLE once and does not throw", () => {
const gl = createMockGl();
const diagnostics: string[] = [];
const surface = new WebGlRenderSurface({
gl,
getSize: () => ({ width: 320, height: 180 }),
onDiagnostic: (code) => diagnostics.push(code)
});
expect(() =>
surface.submit([
{ type: "text", text: "A", x: 1, y: 1 },
{ type: "text", text: "B", x: 2, y: 2 }
])
).not.toThrow();
// 文本无法栅格化时不应抛错且诊断码只推一次one-time
expect(diagnostics.filter((code) => code === "WEBGL_TEXT_LAYER_UNAVAILABLE")).toHaveLength(1);
// 没有文本图层时不能上传纹理。
expect(gl.calls.some((call) => call[0] === "texImage2D")).toBe(false);
});
it("changed commands across submits issue different draw state (interactivity at surface level)", () => {
const gl = createMockGl();
const surface = new WebGlRenderSurface({ gl, getSize: () => ({ width: 100, height: 100 }) });
surface.submit([{ type: "rect", x: 0, y: 0, width: 10, height: 10, fill: "#ff0000" }]);
const firstColors = gl.calls.filter((call) => call[0] === "uniform4f").map((call) => call.slice(1));
gl.calls.length = 0;
surface.submit([{ type: "rect", x: 50, y: 50, width: 10, height: 10, fill: "#00ff00" }]);
const secondColors = gl.calls.filter((call) => call[0] === "uniform4f").map((call) => call.slice(1));
// 不同帧的命令导致不同的着色器 uniform颜色状态证明表面级可交互。
expect(secondColors).not.toEqual(firstColors);
});
it("throws a typed init error when shader compilation fails so the factory can fall back", () => {
const gl = createMockGl({ failCompile: true });
expect(() => new WebGlRenderSurface({ gl, getSize: () => ({ width: 10, height: 10 }) })).toThrow(WebGlRenderSurfaceInitError);
});
it("throws a typed init error when program linking fails", () => {
const gl = createMockGl({ failLink: true });
expect(() => new WebGlRenderSurface({ gl, getSize: () => ({ width: 10, height: 10 }) })).toThrow(WebGlRenderSurfaceInitError);
});
it("deletes attached shaders after a successful link so they do not leak", () => {
const gl = createMockGl();
new WebGlRenderSurface({ gl, getSize: () => ({ width: 10, height: 10 }) });
// 两套程序各编译两个着色器(共 4 个),链接成功后应 detach + delete 各 4 次。
expect(gl.calls.filter((call) => call[0] === "detachShader")).toHaveLength(4);
});
it("dispose frees GL programs/buffers/texture and is idempotent", () => {
const gl = createMockGl();
const surface = new WebGlRenderSurface({ gl, getSize: () => ({ width: 10, height: 10 }) });
surface.dispose();
// 第一次 dispose 释放两套程序、两个缓冲、一张纹理。
expect(gl.calls.filter((call) => call[0] === "deleteProgram")).toHaveLength(2);
expect(gl.calls.filter((call) => call[0] === "deleteBuffer")).toHaveLength(2);
expect(gl.calls.filter((call) => call[0] === "deleteTexture")).toHaveLength(1);
// 重复 dispose 幂等:不得再次对同一 GL 句柄发起删除。
surface.dispose();
expect(gl.calls.filter((call) => call[0] === "deleteProgram")).toHaveLength(2);
expect(gl.calls.filter((call) => call[0] === "deleteBuffer")).toHaveLength(2);
expect(gl.calls.filter((call) => call[0] === "deleteTexture")).toHaveLength(1);
});
});
// --- mocks ---
type MockGl = GLLike & {
readonly calls: unknown[][];
programCount: number;
};
function createMockGl(options: { failCompile?: boolean; failLink?: boolean } = {}): MockGl {
const calls: unknown[][] = [];
const record = (name: string, ...args: unknown[]): void => {
calls.push([name, ...args]);
};
const handle = (): WebGlObject => ({});
const gl: MockGl = {
calls,
programCount: 0,
COLOR_BUFFER_BIT: 0x4000,
VERTEX_SHADER: 0x8b31,
FRAGMENT_SHADER: 0x8b30,
COMPILE_STATUS: 0x8b81,
LINK_STATUS: 0x8b82,
ARRAY_BUFFER: 0x8892,
STATIC_DRAW: 0x88e4,
DYNAMIC_DRAW: 0x88e8,
FLOAT: 0x1406,
TRIANGLES: 0x0004,
TRIANGLE_STRIP: 0x0005,
TEXTURE_2D: 0x0de1,
TEXTURE0: 0x84c0,
RGBA: 0x1908,
UNSIGNED_BYTE: 0x1401,
TEXTURE_MIN_FILTER: 0x2801,
TEXTURE_MAG_FILTER: 0x2800,
TEXTURE_WRAP_S: 0x2802,
TEXTURE_WRAP_T: 0x2803,
LINEAR: 0x2601,
CLAMP_TO_EDGE: 0x812f,
BLEND: 0x0be2,
SRC_ALPHA: 0x0302,
ONE_MINUS_SRC_ALPHA: 0x0303,
viewport: (...a) => record("viewport", ...a),
clearColor: (...a) => record("clearColor", ...a),
clear: (...a) => record("clear", ...a),
createShader: () => handle(),
shaderSource: () => undefined,
compileShader: () => undefined,
getShaderParameter: () => options.failCompile !== true,
getShaderInfoLog: () => "mock shader log",
deleteShader: () => undefined,
createProgram: () => {
gl.programCount += 1;
return handle();
},
attachShader: () => undefined,
detachShader: (...a) => record("detachShader", ...a),
linkProgram: () => undefined,
getProgramParameter: () => options.failLink !== true,
getProgramInfoLog: () => "mock program log",
useProgram: (...a) => record("useProgram", ...a),
deleteProgram: (...a) => record("deleteProgram", ...a),
getAttribLocation: () => 0,
getUniformLocation: () => handle(),
createBuffer: () => handle(),
bindBuffer: (...a) => record("bindBuffer", ...a),
bufferData: (target, data, usage) => record("bufferData", target, data.byteLength, usage),
deleteBuffer: (...a) => record("deleteBuffer", ...a),
enableVertexAttribArray: (...a) => record("enableVertexAttribArray", ...a),
vertexAttribPointer: (...a) => record("vertexAttribPointer", ...a),
uniform4f: (_loc, x, y, z, w) => record("uniform4f", x, y, z, w),
uniform1i: (_loc, x) => record("uniform1i", x),
enable: (...a) => record("enable", ...a),
blendFunc: (...a) => record("blendFunc", ...a),
createTexture: () => handle(),
bindTexture: (...a) => record("bindTexture", ...a),
deleteTexture: (...a) => record("deleteTexture", ...a),
activeTexture: (...a) => record("activeTexture", ...a),
texParameteri: (...a) => record("texParameteri", ...a),
texImage2D: (target, level, internalformat, width, height) => record("texImage2D", target, level, internalformat, width, height),
drawArrays: (...a) => record("drawArrays", ...a)
};
return gl;
}
function createMockTextLayer(width: number, height: number): TextLayerLike & { context2d: { drawnTexts: string[] } } {
const drawnTexts: string[] = [];
const context2d = {
fillStyle: "",
font: "",
drawnTexts,
clearRect: () => undefined,
fillText: (text: string) => {
drawnTexts.push(text);
},
getImageData: (_x: number, _y: number, w: number, h: number) => ({
data: new Uint8ClampedArray(w * h * 4),
width: w,
height: h
})
};
return { width, height, context2d };
}

View File

@ -0,0 +1,330 @@
import type { RenderCommand } from "../../shared-contracts/src/runtime-sdk-contract";
import type { GLLike, RenderSurface, TextLayerLike, WebGlObject } from "./RenderSurface";
import { WebGlRenderSurfaceInitError } from "./RenderSurface";
// WebGlRenderSurface 构造入参:注入 GL 上下文、画布尺寸读取器,可选文本图层与诊断回调。
export type WebGlRenderSurfaceInput = {
readonly gl: GLLike;
readonly getSize: () => { readonly width: number; readonly height: number };
// 文本图层(离屏 2D 画布)用于栅格化文字;缺省则 text 命令被跳过并发一次诊断。
readonly textLayer?: TextLayerLike;
// 诊断回调:用于上报一次性提示,例如 WEBGL_TEXT_LAYER_UNAVAILABLE。
readonly onDiagnostic?: (code: string) => void;
};
// 纯色矩形 / 纹理四边形共用的顶点着色器:
// a_pos 已经是 NDC 裁剪空间坐标(-1..1a_uv 传给片元用于纹理采样。
const VERTEX_SHADER_SOURCE = `
attribute vec2 a_pos;
attribute vec2 a_uv;
varying vec2 v_uv;
void main() {
v_uv = a_uv;
gl_Position = vec4(a_pos, 0.0, 1.0);
}
`;
// 纯色片元着色器:直接输出 uniform 颜色,用于绘制 rect 实心方块。
const RECT_FRAGMENT_SHADER_SOURCE = `
precision mediump float;
uniform vec4 u_color;
void main() {
gl_FragColor = u_color;
}
`;
// 纹理片元着色器:采样文本图层纹理,用于把栅格化文字合成到场景上。
const TEXTURE_FRAGMENT_SHADER_SOURCE = `
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_texture;
void main() {
gl_FragColor = texture2D(u_texture, v_uv);
}
`;
// WebGlRenderSurface用 raw WebGL无第三方引擎实现 clear/rect/text 三类命令。
// 任何初始化/编译/链接失败都会抛出 WebGlRenderSurfaceInitError交给工厂回退 Canvas2D。
export class WebGlRenderSurface implements RenderSurface {
readonly kind = "webgl" as const;
private readonly gl: GLLike;
private readonly getSize: () => { readonly width: number; readonly height: number };
// 显式包含 undefined仓库开启 exactOptionalPropertyTypes可选字段需可承接 undefined。
private readonly textLayer: TextLayerLike | undefined;
private readonly onDiagnostic: ((code: string) => void) | undefined;
// 纯色 rect 程序及其属性/uniform 位置(构造时编译一次,逐帧复用)。
private readonly rectProgram: WebGlObject;
private readonly rectPosLoc: number;
private readonly rectColorLoc: WebGlObject | null;
private readonly rectBuffer: WebGlObject;
// 纹理 text 程序及其属性/uniform 位置(构造时编译一次,逐帧复用)。
private readonly texProgram: WebGlObject;
private readonly texPosLoc: number;
private readonly texUvLoc: number;
private readonly texSamplerLoc: WebGlObject | null;
private readonly texBuffer: WebGlObject;
private readonly texture: WebGlObject;
// 一次性诊断去重:避免每帧重复上报同一提示码。
private readonly emittedDiagnostics = new Set<string>();
// dispose 幂等守卫:防止重复释放 GL 资源(重复 deleteProgram/deleteBuffer 等)。
private disposed = false;
constructor(input: WebGlRenderSurfaceInput) {
this.gl = input.gl;
this.getSize = input.getSize;
this.textLayer = input.textLayer;
this.onDiagnostic = input.onDiagnostic;
const gl = this.gl;
// 编译两套程序rect 纯色 + texture 文本。任一失败抛类型化错误,让工厂回退。
this.rectProgram = this.createProgram(VERTEX_SHADER_SOURCE, RECT_FRAGMENT_SHADER_SOURCE);
this.rectPosLoc = gl.getAttribLocation(this.rectProgram, "a_pos");
this.rectColorLoc = gl.getUniformLocation(this.rectProgram, "u_color");
this.texProgram = this.createProgram(VERTEX_SHADER_SOURCE, TEXTURE_FRAGMENT_SHADER_SOURCE);
this.texPosLoc = gl.getAttribLocation(this.texProgram, "a_pos");
this.texUvLoc = gl.getAttribLocation(this.texProgram, "a_uv");
this.texSamplerLoc = gl.getUniformLocation(this.texProgram, "u_texture");
// 顶点缓冲与文本纹理也只创建一次。
this.rectBuffer = this.requireObject(gl.createBuffer(), "WEBGL_BUFFER_CREATE_FAILED");
this.texBuffer = this.requireObject(gl.createBuffer(), "WEBGL_BUFFER_CREATE_FAILED");
this.texture = this.requireObject(gl.createTexture(), "WEBGL_TEXTURE_CREATE_FAILED");
// 启用透明混合,便于文本纹理叠加在 rect 场景上。
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
// 初始化视口。
const size = this.getSize();
gl.viewport(0, 0, Math.max(1, size.width), Math.max(1, size.height));
}
resize(width: number, height: number): void {
// 视口随画布尺寸更新,保证像素到裁剪空间的映射正确。
this.gl.viewport(0, 0, Math.max(1, width), Math.max(1, height));
}
submit(commands: readonly RenderCommand[]): void {
const gl = this.gl;
const size = this.getSize();
const viewWidth = Math.max(1, size.width);
const viewHeight = Math.max(1, size.height);
for (const command of commands) {
if (command.type === "clear") {
// clear无指定颜色时清成完全透明alpha=0与 Canvas2D 的 clearRect 透明语义保持一致;
// 指定颜色时铺该底色并设为不透明alpha=1与 Canvas2D 的 fillRect 铺底行为一致。
const [r, g, b] = parseColor(command.color ?? "#000000");
gl.clearColor(r, g, b, command.color === undefined ? 0 : 1);
gl.clear(gl.COLOR_BUFFER_BIT);
continue;
}
if (command.type === "rect") {
// rect把像素坐标映射到裁剪空间画两个三角形组成的实心四边形。
this.drawRect(command.x, command.y, command.width, command.height, command.fill ?? "#ffffff", viewWidth, viewHeight);
continue;
}
if (command.type === "text") {
// text用注入的离屏 2D 图层栅格化文字,再作为纹理合成;无图层则跳过并发一次诊断。
this.drawText(command, viewWidth, viewHeight);
}
// sprite 命令在 S4 范围内未实现,保持与 Canvas2D 一致地忽略。
}
}
dispose(): void {
// 幂等:已释放则直接返回,避免对同一 GL 句柄重复 delete。
if (this.disposed) return;
this.disposed = true;
const gl = this.gl;
// 释放构造期一次性创建的 GL 资源:两套程序、两个顶点缓冲、一张文本纹理。
gl.deleteProgram(this.rectProgram);
gl.deleteProgram(this.texProgram);
gl.deleteBuffer(this.rectBuffer);
gl.deleteBuffer(this.texBuffer);
gl.deleteTexture(this.texture);
// 清空一次性诊断去重集合,便于宿主回收后状态干净。
this.emittedDiagnostics.clear();
}
// drawRect构造两三角形6 顶点)四边形并以 uniform 颜色绘制。
private drawRect(x: number, y: number, width: number, height: number, fill: string, viewWidth: number, viewHeight: number): void {
const gl = this.gl;
const vertices = quadVertices(x, y, width, height, viewWidth, viewHeight);
gl.useProgram(this.rectProgram);
gl.bindBuffer(gl.ARRAY_BUFFER, this.rectBuffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(this.rectPosLoc);
gl.vertexAttribPointer(this.rectPosLoc, 2, gl.FLOAT, false, 0, 0);
const [r, g, b] = parseColor(fill);
gl.uniform4f(this.rectColorLoc, r, g, b, 1);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
// drawText把文字写到离屏 2D 图层 -> 上传为纹理 -> 在文字落点处画一个带 UV 的纹理四边形。
private drawText(command: Extract<RenderCommand, { type: "text" }>, viewWidth: number, viewHeight: number): void {
const gl = this.gl;
const layer = this.textLayer;
if (layer === undefined) {
// 没有文本图层:场景仍由 rect 维持非空,这里只发一次性诊断,绝不抛错。
this.emitOnce("WEBGL_TEXT_LAYER_UNAVAILABLE");
return;
}
const fontSize = command.fontSize ?? 12;
const ctx = layer.context2d;
// 把整张图层清空后写入本条文字(简单实现,每条文本独占一次上传,不做批处理)。
ctx.clearRect(0, 0, layer.width, layer.height);
ctx.fillStyle = command.fill ?? "#ffffff";
ctx.font = `${fontSize}px sans-serif`;
ctx.fillText(command.text, 0, fontSize);
const image = ctx.getImageData(0, 0, layer.width, layer.height);
// 上传纹理。
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, layer.width, layer.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, toUint8(image.data));
// 文字四边形fillText 以 baseline=fontSize 绘制,所以贴图顶部对应 command.y - fontSize。
const top = command.y - fontSize;
const interleaved = quadWithUv(command.x, top, layer.width, layer.height, viewWidth, viewHeight);
gl.useProgram(this.texProgram);
gl.bindBuffer(gl.ARRAY_BUFFER, this.texBuffer);
gl.bufferData(gl.ARRAY_BUFFER, interleaved, gl.DYNAMIC_DRAW);
// 交错布局:每顶点 4 个 floatx,y,u,v步长 16 字节。
const stride = 4 * Float32Array.BYTES_PER_ELEMENT;
gl.enableVertexAttribArray(this.texPosLoc);
gl.vertexAttribPointer(this.texPosLoc, 2, gl.FLOAT, false, stride, 0);
gl.enableVertexAttribArray(this.texUvLoc);
gl.vertexAttribPointer(this.texUvLoc, 2, gl.FLOAT, false, stride, 2 * Float32Array.BYTES_PER_ELEMENT);
gl.uniform1i(this.texSamplerLoc, 0);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
// emitOnce同一诊断码只上报一次避免逐帧刷屏。
private emitOnce(code: string): void {
if (this.emittedDiagnostics.has(code)) return;
this.emittedDiagnostics.add(code);
this.onDiagnostic?.(code);
}
// createProgram编译顶点/片元着色器并链接,任一步失败抛类型化初始化错误。
private createProgram(vertexSource: string, fragmentSource: string): WebGlObject {
const gl = this.gl;
const vertexShader = this.compileShader(gl.VERTEX_SHADER, vertexSource);
const fragmentShader = this.compileShader(gl.FRAGMENT_SHADER, fragmentSource);
const program = this.requireObject(gl.createProgram(), "WEBGL_PROGRAM_CREATE_FAILED");
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (gl.getProgramParameter(program, gl.LINK_STATUS) !== true) {
// 链接失败:着色器已编译但未被程序占用,逐个删除避免泄漏,再抛类型化错误供工厂回退。
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
throw new WebGlRenderSurfaceInitError("WEBGL_PROGRAM_LINK_FAILED", gl.getProgramInfoLog(program) ?? undefined);
}
// 链接成功后着色器对象已被链接进程序,可立即解除附着并删除,释放 GL 端的着色器资源。
gl.detachShader(program, vertexShader);
gl.detachShader(program, fragmentShader);
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
return program;
}
// compileShader编译单个着色器失败抛类型化初始化错误。
private compileShader(type: number, source: string): WebGlObject {
const gl = this.gl;
const shader = this.requireObject(gl.createShader(type), "WEBGL_SHADER_CREATE_FAILED");
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS) !== true) {
const log = gl.getShaderInfoLog(shader) ?? undefined;
gl.deleteShader(shader);
throw new WebGlRenderSurfaceInitError("WEBGL_SHADER_COMPILE_FAILED", log);
}
return shader;
}
// requireObject把可能为 null 的 GL 句柄收敛为非空,否则抛类型化初始化错误。
private requireObject(value: WebGlObject | null, reasonCode: string): WebGlObject {
if (value === null) throw new WebGlRenderSurfaceInitError(reasonCode);
return value;
}
}
// quadVertices把像素矩形(x,y,w,h)映射到 NDC 裁剪空间,返回两三角形(6 顶点 * 2 分量)。
function quadVertices(x: number, y: number, width: number, height: number, viewWidth: number, viewHeight: number): Float32Array {
const left = toClipX(x, viewWidth);
const right = toClipX(x + width, viewWidth);
const top = toClipY(y, viewHeight);
const bottom = toClipY(y + height, viewHeight);
// 顶点顺序:左上-右上-左下 / 左下-右上-右下。
return new Float32Array([left, top, right, top, left, bottom, left, bottom, right, top, right, bottom]);
}
// quadWithUv与 quadVertices 同样的位置,但交错附带 UV用于纹理采样。
function quadWithUv(x: number, y: number, width: number, height: number, viewWidth: number, viewHeight: number): Float32Array {
const left = toClipX(x, viewWidth);
const right = toClipX(x + width, viewWidth);
const top = toClipY(y, viewHeight);
const bottom = toClipY(y + height, viewHeight);
// UV纹理顶部 v=0、底部 v=1与 2D 图像行序一致)。
return new Float32Array([
left, top, 0, 0,
right, top, 1, 0,
left, bottom, 0, 1,
left, bottom, 0, 1,
right, top, 1, 0,
right, bottom, 1, 1
]);
}
// 像素 X -> 裁剪空间 X-1..1)。
function toClipX(px: number, viewWidth: number): number {
return (px / viewWidth) * 2 - 1;
}
// 像素 Y -> 裁剪空间 Y顶部 +1、底部 -1匹配 2D 画布的 y 向下)。
function toClipY(py: number, viewHeight: number): number {
return 1 - (py / viewHeight) * 2;
}
// parseColor把 #rgb / #rrggbb 解析为归一化 [r,g,b];无法解析时回退白色。
function parseColor(color: string): readonly [number, number, number] {
const hex = color.trim().replace(/^#/, "");
if (hex.length === 3) {
const r = Number.parseInt(hex[0]! + hex[0]!, 16);
const g = Number.parseInt(hex[1]! + hex[1]!, 16);
const b = Number.parseInt(hex[2]! + hex[2]!, 16);
if ([r, g, b].every(Number.isFinite)) return [r / 255, g / 255, b / 255];
}
if (hex.length === 6) {
const r = Number.parseInt(hex.slice(0, 2), 16);
const g = Number.parseInt(hex.slice(2, 4), 16);
const b = Number.parseInt(hex.slice(4, 6), 16);
if ([r, g, b].every(Number.isFinite)) return [r / 255, g / 255, b / 255];
}
// 无法解析的颜色回退白色,保证至少有可见输出。
return [1, 1, 1];
}
// toUint8把图层像素数据规整为 Uint8Array供 texImage2D 上传。
// 单次拷贝new Uint8Array(ArrayLike) 直接按元素填充,避免先转中间数组再转的双重拷贝。
function toUint8(data: ArrayLike<number>): Uint8Array {
if (data instanceof Uint8Array) return data;
return new Uint8Array(data);
}

View File

@ -0,0 +1,170 @@
import { describe, expect, it } from "vitest";
import { createRenderSurface } from "./createRenderSurface";
import type { Ctx2dLike, GLLike, WebGlObject } from "./RenderSurface";
describe("createRenderSurface", () => {
it("preference webgl with a working GL context selects the webgl surface", () => {
const result = createRenderSurface({
preference: "webgl",
getSize: () => ({ width: 320, height: 180 }),
tryWebgl: () => createWorkingGl(),
get2d: () => createCtx2d(),
onDiagnostic: () => undefined
});
expect(result.kind).toBe("webgl");
expect(result.surface.kind).toBe("webgl");
expect(result.diagnostics).toEqual([]);
});
it("preference webgl with tryWebgl()=>null falls back to canvas2d with diagnostic", () => {
const diagnostics: string[] = [];
const result = createRenderSurface({
preference: "webgl",
getSize: () => ({ width: 320, height: 180 }),
tryWebgl: () => null,
get2d: () => createCtx2d(),
onDiagnostic: (code) => diagnostics.push(code)
});
expect(result.kind).toBe("canvas2d");
expect(result.diagnostics).toContain("WEBGL_UNAVAILABLE_FALLBACK_CANVAS2D");
expect(diagnostics).toContain("WEBGL_UNAVAILABLE_FALLBACK_CANVAS2D");
});
it("preference webgl where WebGL init throws falls back to canvas2d with init-failed diagnostic", () => {
const diagnostics: string[] = [];
const result = createRenderSurface({
preference: "webgl",
getSize: () => ({ width: 320, height: 180 }),
// 返回会导致 shader 编译失败的 GL使 WebGlRenderSurface 构造抛错。
tryWebgl: () => createWorkingGl({ failCompile: true }),
get2d: () => createCtx2d(),
onDiagnostic: (code) => diagnostics.push(code)
});
expect(result.kind).toBe("canvas2d");
expect(result.diagnostics).toContain("WEBGL_INIT_FAILED_FALLBACK_CANVAS2D");
expect(diagnostics).toContain("WEBGL_INIT_FAILED_FALLBACK_CANVAS2D");
});
it("preference canvas2d selects canvas2d without touching webgl", () => {
let webglTried = false;
const result = createRenderSurface({
preference: "canvas2d",
getSize: () => ({ width: 320, height: 180 }),
tryWebgl: () => {
webglTried = true;
return createWorkingGl();
},
get2d: () => createCtx2d(),
onDiagnostic: () => undefined
});
expect(result.kind).toBe("canvas2d");
expect(webglTried).toBe(false);
expect(result.diagnostics).toEqual([]);
});
it("preference canvas2d with null 2d throws the existing canvas-2d-unavailable error", () => {
expect(() =>
createRenderSurface({
preference: "canvas2d",
getSize: () => ({ width: 1, height: 1 }),
tryWebgl: () => null,
get2d: () => null,
onDiagnostic: () => undefined
})
).toThrow("WEB_PLATFORM_CANVAS_2D_UNAVAILABLE");
});
it("preference webgl with both webgl and 2d unavailable throws the render-surface-unavailable error", () => {
expect(() =>
createRenderSurface({
preference: "webgl",
getSize: () => ({ width: 1, height: 1 }),
tryWebgl: () => null,
get2d: () => null,
onDiagnostic: () => undefined
})
).toThrow("WEB_PLATFORM_RENDER_SURFACE_UNAVAILABLE");
});
});
// --- mocks ---
function createCtx2d(): Ctx2dLike {
return {
fillStyle: "",
font: "",
clearRect: () => undefined,
fillRect: () => undefined,
fillText: () => undefined
};
}
function createWorkingGl(options: { failCompile?: boolean } = {}): GLLike {
const handle = (): WebGlObject => ({});
return {
COLOR_BUFFER_BIT: 0x4000,
VERTEX_SHADER: 0x8b31,
FRAGMENT_SHADER: 0x8b30,
COMPILE_STATUS: 0x8b81,
LINK_STATUS: 0x8b82,
ARRAY_BUFFER: 0x8892,
STATIC_DRAW: 0x88e4,
DYNAMIC_DRAW: 0x88e8,
FLOAT: 0x1406,
TRIANGLES: 0x0004,
TRIANGLE_STRIP: 0x0005,
TEXTURE_2D: 0x0de1,
TEXTURE0: 0x84c0,
RGBA: 0x1908,
UNSIGNED_BYTE: 0x1401,
TEXTURE_MIN_FILTER: 0x2801,
TEXTURE_MAG_FILTER: 0x2800,
TEXTURE_WRAP_S: 0x2802,
TEXTURE_WRAP_T: 0x2803,
LINEAR: 0x2601,
CLAMP_TO_EDGE: 0x812f,
BLEND: 0x0be2,
SRC_ALPHA: 0x0302,
ONE_MINUS_SRC_ALPHA: 0x0303,
viewport: () => undefined,
clearColor: () => undefined,
clear: () => undefined,
createShader: () => handle(),
shaderSource: () => undefined,
compileShader: () => undefined,
getShaderParameter: () => options.failCompile !== true,
getShaderInfoLog: () => null,
deleteShader: () => undefined,
createProgram: () => handle(),
attachShader: () => undefined,
detachShader: () => undefined,
linkProgram: () => undefined,
getProgramParameter: () => true,
getProgramInfoLog: () => null,
useProgram: () => undefined,
deleteProgram: () => undefined,
getAttribLocation: () => 0,
getUniformLocation: () => handle(),
createBuffer: () => handle(),
bindBuffer: () => undefined,
bufferData: () => undefined,
deleteBuffer: () => undefined,
enableVertexAttribArray: () => undefined,
vertexAttribPointer: () => undefined,
uniform4f: () => undefined,
uniform1i: () => undefined,
enable: () => undefined,
blendFunc: () => undefined,
createTexture: () => handle(),
bindTexture: () => undefined,
deleteTexture: () => undefined,
activeTexture: () => undefined,
texParameteri: () => undefined,
texImage2D: () => undefined,
drawArrays: () => undefined
};
}

View File

@ -0,0 +1,74 @@
import { Canvas2dRenderSurface } from "./Canvas2dRenderSurface";
import type { Ctx2dLike, GLLike, RenderSurface, TextLayerLike } from "./RenderSurface";
import { WebGlRenderSurface } from "./WebGlRenderSurface";
// 渲染器偏好canvas2d 为默认webgl 为显式 opt-in。
export type RendererPreference = "canvas2d" | "webgl";
// createRenderSurface 入参:所有 DOM 相关访问都以函数注入,保持本模块 DOM-free 可测。
export type CreateRenderSurfaceInput = {
readonly preference: RendererPreference;
// 画布像素尺寸读取器clear/视口等都需要。
readonly getSize: () => { readonly width: number; readonly height: number };
// 尝试获取 WebGL 上下文;不可用返回 null。
readonly tryWebgl: () => GLLike | null;
// 获取 2D 上下文;不可用返回 null。
readonly get2d: () => Ctx2dLike | null;
// 可选:为 WebGL 文本栅格化创建离屏图层;返回 null 表示无文本图层。
readonly createTextLayer?: (width: number, height: number) => TextLayerLike | null;
// 诊断回调:记录回退原因等。
readonly onDiagnostic: (code: string) => void;
};
// createRenderSurface 返回所选渲染面、其类型,以及本次选择过程产生的诊断码集合。
export type CreateRenderSurfaceResult = {
readonly surface: RenderSurface;
readonly kind: RenderSurface["kind"];
readonly diagnostics: readonly string[];
};
// 文本图层默认尺寸足够容纳一行短文本HUD 用途);尺寸过大无意义。
const DEFAULT_TEXT_LAYER_WIDTH = 256;
const DEFAULT_TEXT_LAYER_HEIGHT = 64;
// createRenderSurface根据偏好选择渲染面。WebGL 仅在显式 opt-in 时尝试,失败一律安全回退 Canvas2D。
export function createRenderSurface(input: CreateRenderSurfaceInput): CreateRenderSurfaceResult {
const diagnostics: string[] = [];
const recordDiagnostic = (code: string): void => {
diagnostics.push(code);
input.onDiagnostic(code);
};
if (input.preference === "webgl") {
const gl = input.tryWebgl();
if (gl !== null) {
// WebGL 上下文可用:尝试构造渲染面(内部会编译着色器/创建缓冲)。
try {
const textLayer = input.createTextLayer?.(DEFAULT_TEXT_LAYER_WIDTH, DEFAULT_TEXT_LAYER_HEIGHT) ?? undefined;
const surface = new WebGlRenderSurface({
gl,
getSize: input.getSize,
...(textLayer === undefined ? {} : { textLayer }),
onDiagnostic: recordDiagnostic
});
return { surface, kind: "webgl", diagnostics };
} catch {
// 初始化/编译/链接失败(无论是否 WebGlRenderSurfaceInitError统一记录回退诊断落到 Canvas2D。
recordDiagnostic("WEBGL_INIT_FAILED_FALLBACK_CANVAS2D");
}
} else {
// WebGL 上下文不可用:记录回退诊断,落到 Canvas2D。
recordDiagnostic("WEBGL_UNAVAILABLE_FALLBACK_CANVAS2D");
}
// 回退 Canvas2D若 2D 也不可用,则整体无可用渲染面。
const context2d = input.get2d();
if (context2d === null) throw new Error("WEB_PLATFORM_RENDER_SURFACE_UNAVAILABLE");
return { surface: new Canvas2dRenderSurface(context2d, input.getSize), kind: "canvas2d", diagnostics };
}
// 默认偏好 canvas2d沿用既有行为2D 不可用抛既有错误码,保持兼容。
const context2d = input.get2d();
if (context2d === null) throw new Error("WEB_PLATFORM_CANVAS_2D_UNAVAILABLE");
return { surface: new Canvas2dRenderSurface(context2d, input.getSize), kind: "canvas2d", diagnostics };
}

View File

@ -1,3 +1,7 @@
export * from "./Canvas2dRenderer";
export * from "./WebRuntimeHost";
export * from "./runtime-dependency-gate";
export * from "./RenderSurface";
export * from "./Canvas2dRenderSurface";
export * from "./WebGlRenderSurface";
export * from "./createRenderSurface";