From 7c7879cfe88236e0390f2fb053d41d6551e084d0 Mon Sep 17 00:00:00 2001 From: zizi Date: Fri, 5 Jun 2026 23:47:58 +0000 Subject: [PATCH] feat(s4): add opt-in webgl render surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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) --- .../src/app/preview/[versionId]/page.test.tsx | 24 ++ apps/web/src/app/preview/[versionId]/page.tsx | 81 ++--- .../runtime/PreviewRuntimeClient.tsx | 69 ++++ .../runtime/WebGameRuntime.test.tsx | 20 +- .../src/components/runtime/WebGameRuntime.tsx | 37 +- .../runtime/WebPlatformAdapter.test.ts | 156 ++++++++- .../components/runtime/WebPlatformAdapter.ts | 114 ++++-- docs/memorys/2026-06-05-MVP剩余清单与路线.md | 318 +++++++++++++++++ docs/memorys/2026-06-05-WebPlayable里程碑.md | 117 +++++++ .../src/Canvas2dRenderSurface.spec.ts | 70 ++++ .../web-runtime/src/Canvas2dRenderSurface.ts | 49 +++ packages/web-runtime/src/RenderSurface.ts | 131 +++++++ .../src/WebGlRenderSurface.spec.ts | 257 ++++++++++++++ .../web-runtime/src/WebGlRenderSurface.ts | 330 ++++++++++++++++++ .../src/createRenderSurface.spec.ts | 170 +++++++++ .../web-runtime/src/createRenderSurface.ts | 74 ++++ packages/web-runtime/src/index.ts | 4 + 17 files changed, 1923 insertions(+), 98 deletions(-) create mode 100644 apps/web/src/app/preview/[versionId]/page.test.tsx create mode 100644 apps/web/src/components/runtime/PreviewRuntimeClient.tsx create mode 100644 docs/memorys/2026-06-05-MVP剩余清单与路线.md create mode 100644 docs/memorys/2026-06-05-WebPlayable里程碑.md create mode 100644 packages/web-runtime/src/Canvas2dRenderSurface.spec.ts create mode 100644 packages/web-runtime/src/Canvas2dRenderSurface.ts create mode 100644 packages/web-runtime/src/RenderSurface.ts create mode 100644 packages/web-runtime/src/WebGlRenderSurface.spec.ts create mode 100644 packages/web-runtime/src/WebGlRenderSurface.ts create mode 100644 packages/web-runtime/src/createRenderSurface.spec.ts create mode 100644 packages/web-runtime/src/createRenderSurface.ts diff --git a/apps/web/src/app/preview/[versionId]/page.test.tsx b/apps/web/src/app/preview/[versionId]/page.test.tsx new file mode 100644 index 00000000..ae686bd7 --- /dev/null +++ b/apps/web/src/app/preview/[versionId]/page.test.tsx @@ -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(); + }); +}); diff --git a/apps/web/src/app/preview/[versionId]/page.tsx b/apps/web/src/app/preview/[versionId]/page.tsx index dba98550..030ea617 100644 --- a/apps/web/src/app/preview/[versionId]/page.tsx +++ b/apps/web/src/app/preview/[versionId]/page.tsx @@ -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 16:page 的 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; +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; } diff --git a/apps/web/src/components/runtime/PreviewRuntimeClient.tsx b/apps/web/src/components/runtime/PreviewRuntimeClient.tsx new file mode 100644 index 00000000..0abfb4bb --- /dev/null +++ b/apps/web/src/components/runtime/PreviewRuntimeClient.tsx @@ -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; + +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) + ); +} diff --git a/apps/web/src/components/runtime/WebGameRuntime.test.tsx b/apps/web/src/components/runtime/WebGameRuntime.test.tsx index 3b841acc..bb10584e 100644 --- a/apps/web/src/components/runtime/WebGameRuntime.test.tsx +++ b/apps/web/src/components/runtime/WebGameRuntime.test.tsx @@ -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", () => { diff --git a/apps/web/src/components/runtime/WebGameRuntime.tsx b/apps/web/src/components/runtime/WebGameRuntime.tsx index 7d3f7c3a..ab20367c 100644 --- a/apps/web/src/components/runtime/WebGameRuntime.tsx +++ b/apps/web/src/components/runtime/WebGameRuntime.tsx @@ -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) | undefined; readonly reader?: WebRuntimeHostPackageReader | undefined; + // 渲染器偏好(opt-in):缺省时维持默认 canvas2d。 + readonly rendererPreference?: RendererPreference; readonly scheduler?: WebRuntimeHostScheduler; readonly storage: WebStorage; readonly telemetrySender?: ((events: readonly TelemetryEventDto[]) => Promise) | undefined; @@ -49,6 +54,8 @@ type WebGameRuntimeProps = { readonly initialSnapshot?: PreviewRuntimeSnapshot; readonly loadLogicModule?: ((packageLogicPath: string) => Promise) | undefined; readonly packageReader?: WebRuntimeHostPackageReader | undefined; + // 渲染器偏好(opt-in):缺省时默认 canvas2d,由预览页根据查询参数传入。 + readonly rendererPreference?: RendererPreference; }; const PREVIEW_CONFIG_TEXT = `{ @@ -198,7 +205,7 @@ const styles = { } } satisfies Record; -export function WebGameRuntime({ gamePackage, initialSnapshot, loadLogicModule, packageReader }: WebGameRuntimeProps) { +export function WebGameRuntime({ gamePackage, initialSnapshot, loadLogicModule, packageReader, rendererPreference }: WebGameRuntimeProps) { const [snapshot, setSnapshot] = useState(initialSnapshot ?? createSnapshot("loading")); const canvasRef = useRef(null); const controllerRef = useRef(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 { diff --git a/apps/web/src/components/runtime/WebPlatformAdapter.test.ts b/apps/web/src/components/runtime/WebPlatformAdapter.test.ts index 94b958ff..6e8893a1 100644 --- a/apps/web/src/components/runtime/WebPlatformAdapter.test.ts +++ b/apps/web/src/components/runtime/WebPlatformAdapter.test.ts @@ -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 上下文、但着色器编译会失败的 canvas:WebGlRenderSurface 构造抛错。 + 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) => void>>(); const windowListeners = new Map) => void>>(); const createTarget = (listenerMap: Map) => 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(); @@ -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) + }; +} diff --git a/apps/web/src/components/runtime/WebPlatformAdapter.ts b/apps/web/src/components/runtime/WebPlatformAdapter.ts index 609c8b7a..4c6da53d 100644 --- a/apps/web/src/components/runtime/WebPlatformAdapter.ts +++ b/apps/web/src/components/runtime/WebPlatformAdapter.ts @@ -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"; +// 渲染器偏好:默认 canvas2d;webgl 为显式 opt-in(preference 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; @@ -54,19 +77,74 @@ export type WebPlatformAdapterOptions = { readonly fetcher: WebFetcher; readonly endpoints?: Record; readonly retryDelayMs?: number; + // 渲染器偏好(opt-in):缺省 undefined => "canvas2d",保持既有默认行为不变。 + readonly rendererPreference?: RendererPreference; + // 可选注入的 document,用于在缺少 OffscreenCanvas 时创建文本图层离屏画布。 + readonly documentTarget?: WebDocumentTarget; }; export class WebPlatformAdapter { private readonly canvasHandles = new Set(); private readonly audioHandles = new Set(); - 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 路径栅格化文字时使用;无可用离屏画布时返回 null(WebGL 会跳过文本并发诊断)。 + 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 { diff --git a/docs/memorys/2026-06-05-MVP剩余清单与路线.md b/docs/memorys/2026-06-05-MVP剩余清单与路线.md new file mode 100644 index 00000000..1306ca61 --- /dev/null +++ b/docs/memorys/2026-06-05-MVP剩余清单与路线.md @@ -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 PASS(23 files / 244 tests),见 `2026-06-05-S4Task5运行冒烟.md` | +| S5 mini-game conversion | static_validated 仅;import cert = No-Go | 三端 static gate PASS,DevTool 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. 已完成(S0–S5 + Web playable demo slice) + +### S0–S3:harness + 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。 + +### S4:Web 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` PASS,244 tests,`pnpm typecheck`/`lint` PASS。 +- **S4 = Web runtime green,基础已具备 Web playable demo slice 推进条件。** + +### S5:Mini-game conversion(static_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 并行汇合验证 PASS,merge 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 必须有真实 Postgres:S6T2 / 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 排序算法。 +- 评论、弹幕、粉丝体系。 +- 渠道提审、广告、支付、结算。 + +--- + +### S7:Feedback / 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。 + +**验证分级** + +- unit:S7-T1(schema)、S7-T6(final gate)。 +- integration-db:S7-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` 聚合统计。 +- 简单规则建议(加载失败高、试玩低、留存低、举报高、互动高)。 +- 数据保留 / 冷归档策略。 + +--- + +### S8:Deploy Readiness / MVP Go-No-Go + +**状态:not_started** + +**P0 任务清单** + +| 任务 ID | 标题 | 文件路径 | 依赖 | 验证类型 | +|---------|------|---------|------|---------| +| s8-t1 | 环境配置校验 | `scripts/validate-env.mjs` | — | unit(静态校验)| +| s8-t2 | 健康检查 | `apps/api` health endpoint,worker 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)** + +- 三平台真机 smoke(wechat/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`,linux,node 可用)的验证能力边界: + +**可以产出真实证据的命令** + +``` +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 +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 cert(DevTools passed evidence)**不阻塞 S6 编码**,但阻塞 S6 feed 将 mini-game artifact 用于任何"可玩"声称。 +- S6T1(contracts)可立即开始,无 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 contracts(unit,立即可做) + └── S6T2~T5(串行,需 DB 环境) + +S6 完成后 + ├── S7-T1(unit,可与 S6 后期 overlap) + ├── S7-T4(文档,可随时做) + └── S7-T2~T5(需 DB) + +S7 完成后 + ├── s8-t1(unit,可提前) + ├── s8-t2(unit,可提前) + ├── 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 → PublishedGame(S6 内部,运营必须先审核)。 +3. S0-S7 证据链 → s8-t6 Go/No-Go(所有阶段证据必须完整)。 + +--- + +*本文档基于 git HEAD `98e2aad` 和本 session 状态快照编写。下一个关键更新节点:Web/WebGL playable demo slice 完成后,或 S5 DevTools import cert 状态变更时。* diff --git a/docs/memorys/2026-06-05-WebPlayable里程碑.md b/docs/memorys/2026-06-05-WebPlayable里程碑.md new file mode 100644 index 00000000..aa07271b --- /dev/null +++ b/docs/memorys/2026-06-05-WebPlayable里程碑.md @@ -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+clear;rect→纯色 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 passed(7 files;新增 page.test.tsx + PreviewRuntimeClient 用例 + adapter renderer 用例) | +| 类型检查(4 包) | `pnpm --filter web-runtime/runtime-sdk/web/shared-contracts typecheck` | ✅ Done | +| Lint(3 包) | `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(全绿)。剩余 **F1(critical opt-in)** 与 **F5(dispose 接线)** 由 orchestrator 直接补完: +- F1:查 Next.js 16 官方文档确认 `params/searchParams` 为 Promise,client 页需用 `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 | F1–F7 全部确认解决,无回归;唯一 nit 为既有 s4-scope 基线与 apps/api Prisma(均与本次无关)。 | +| quality review | round-2 `quality-review`(sonnet) | PASS | F1–F7 全部解决;1 个 minor:load 失败后 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 工具自动改写,不纳入提交。) diff --git a/packages/web-runtime/src/Canvas2dRenderSurface.spec.ts b/packages/web-runtime/src/Canvas2dRenderSurface.spec.ts new file mode 100644 index 00000000..4af346af --- /dev/null +++ b/packages/web-runtime/src/Canvas2dRenderSurface.spec.ts @@ -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 }; +} diff --git a/packages/web-runtime/src/Canvas2dRenderSurface.ts b/packages/web-runtime/src/Canvas2dRenderSurface.ts new file mode 100644 index 00000000..4fa8d0b9 --- /dev/null +++ b/packages/web-runtime/src/Canvas2dRenderSurface.ts @@ -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-op:Canvas2D 的视口直接来自宿主画布尺寸访问器,无独立投影状态需要更新。 + } + + 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 类型在旧适配器中也未实现,这里保持一致地忽略,避免引入新行为。 + } + } +} diff --git a/packages/web-runtime/src/RenderSurface.ts b/packages/web-runtime/src/RenderSurface.ts new file mode 100644 index 00000000..9b3aae55 --- /dev/null +++ b/packages/web-runtime/src/RenderSurface.ts @@ -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; 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; + } +} diff --git a/packages/web-runtime/src/WebGlRenderSurface.spec.ts b/packages/web-runtime/src/WebGlRenderSurface.spec.ts new file mode 100644 index 00000000..6daea972 --- /dev/null +++ b/packages/web-runtime/src/WebGlRenderSurface.spec.ts @@ -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 }; +} diff --git a/packages/web-runtime/src/WebGlRenderSurface.ts b/packages/web-runtime/src/WebGlRenderSurface.ts new file mode 100644 index 00000000..38a0709e --- /dev/null +++ b/packages/web-runtime/src/WebGlRenderSurface.ts @@ -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..1),a_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(); + + // 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, 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 个 float(x,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): Uint8Array { + if (data instanceof Uint8Array) return data; + return new Uint8Array(data); +} diff --git a/packages/web-runtime/src/createRenderSurface.spec.ts b/packages/web-runtime/src/createRenderSurface.spec.ts new file mode 100644 index 00000000..b6af3f85 --- /dev/null +++ b/packages/web-runtime/src/createRenderSurface.spec.ts @@ -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 + }; +} diff --git a/packages/web-runtime/src/createRenderSurface.ts b/packages/web-runtime/src/createRenderSurface.ts new file mode 100644 index 00000000..b2f972ca --- /dev/null +++ b/packages/web-runtime/src/createRenderSurface.ts @@ -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 }; +} diff --git a/packages/web-runtime/src/index.ts b/packages/web-runtime/src/index.ts index 45706994..31a0f295 100644 --- a/packages/web-runtime/src/index.ts +++ b/packages/web-runtime/src/index.ts @@ -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";