From 3c7a703ac2904c18737841ae66f3faef4fe11bb7 Mon Sep 17 00:00:00 2001 From: Developer Date: Mon, 17 Aug 2026 16:12:25 +0800 Subject: [PATCH 1/4] Add UIMesh component for custom 2D mesh rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add UIMesh component consuming pre-baked vertex/index/segment data, enabling custom renderers (e.g. spine plugin) to batch through the 2D batcher. - Export UIMesh from the 2d components index. - Validate setMeshData input at the boundary (stride, buffer lengths, index values, segment ranges, accessor caps) via engine-standard errorIDs 9010-9017, documented in EngineErrorMap.md. - Expose StaticVBAccessor per-chunk caps as maxVertexCount/maxIndexCount getters and clamp the 10% reserve to them, so meshes that fit (e.g. 30000 vertices) allocate instead of silently failing. - Add tests/ui/ui-mesh.test.ts covering the 32767 cap boundary, setMeshData validation, and accessor lifetime. Also takes over node transform and cascaded opacity: vertices are now always node-local — UIMesh applies the node's world matrix (unbatched via the per-draw GPU transform the 2D batcher already maintains for middleware, batched by baking into the shared chunk copy so draws still merge) and folds the cascaded UIOpacity into the copied vertex colors itself. The legacy switches (USE_LOCAL / useLocalData / RenderEntity.setUseLocal) are all derived internally; consumers never touch them. A JSB staleness poll re-prepares the native draw infos in the same frame on transform/opacity changes, and a premultipliedAlpha format declaration drives the opacity fold and the builtin material's blend factors. Follow-ups folded in: the opacity fold reads the light color at byte offset 20 (the pos*3f + uv*2f header is 20 bytes), not 16; and UIMesh notifies data producers through the new onPremultipliedAlphaChanged() hook so subclasses (e.g. the spine plugin) forward the declared format to their baker instead of redeclaring the field. Co-Authored-By: Claude Code --- EngineErrorMap.md | 32 ++ cocos/2d/components/index.ts | 1 + cocos/2d/components/ui-mesh.ts | 572 ++++++++++++++++++++++++ cocos/2d/renderer/static-vb-accessor.ts | 6 + tests/ui/ui-mesh.test.ts | 215 +++++++++ 5 files changed, 826 insertions(+) create mode 100644 cocos/2d/components/ui-mesh.ts create mode 100644 tests/ui/ui-mesh.test.ts diff --git a/EngineErrorMap.md b/EngineErrorMap.md index e23ae21fa0..15e4a8aa36 100644 --- a/EngineErrorMap.md +++ b/EngineErrorMap.md @@ -3205,6 +3205,38 @@ request fullscreen is not supported on this platform. exit fullscreen is not supported on this platform. +### 9010 + +UIMesh.setMeshData: unsupported vertexStride %d, expected %d (V3F_T2F_C4B) or %d (V3F_T2F_C4B_C4B); data rejected. + +### 9011 + +UIMesh.setMeshData: negative counts (vertexCount %d, indexCount %d); data rejected. + +### 9012 + +UIMesh.setMeshData: vertexData holds %d bytes, but %d vertices x %d bytes are declared; data rejected. + +### 9013 + +UIMesh.setMeshData: indexData is %d bytes at offset %d, but %d Uint16 indices need %d bytes (2-byte aligned view); data rejected. + +### 9014 + +UIMesh.setMeshData: indices[%d] = %d is out of range for %d vertices; data rejected. + +### 9015 + +UIMesh.setMeshData: segment %d range [%d, %d) is out of bounds for indexCount %d; data rejected. + +### 9016 + +UIMesh.setMeshData: %d vertices / %d indices exceed the accessor cap (%d / %d); split the mesh. Data rejected. + +### 9017 + +UIMesh: render data allocation failed for %d vertices / %d indices (accessor cap: %d / %d). + ### 9100 texture size exceeds current device limits %d/%d diff --git a/cocos/2d/components/index.ts b/cocos/2d/components/index.ts index 9394a45c0c..649e413782 100644 --- a/cocos/2d/components/index.ts +++ b/cocos/2d/components/index.ts @@ -25,6 +25,7 @@ export * from './label'; export { Sprite } from './sprite'; export { UIMeshRenderer } from './ui-mesh-renderer'; +export * from './ui-mesh'; export { LabelOutline } from './label-outline'; export { UIStaticBatch } from './ui-static-batch'; export { LabelShadow } from './label-shadow'; diff --git a/cocos/2d/components/ui-mesh.ts b/cocos/2d/components/ui-mesh.ts new file mode 100644 index 0000000000..dc415d3fde --- /dev/null +++ b/cocos/2d/components/ui-mesh.ts @@ -0,0 +1,572 @@ +/* + Copyright (c) 2026 + Generic 2D mesh data consumer. The plugin / user feeds pre-baked vertex, index + and segment data through setMeshData; this component owns the vertex buffers, + batching and submission (via the 2D batcher). Rendering internals (RenderData / + StaticVBAccessor) stay engine-side, so extensions can render custom meshes + without touching engine internals. + + Vertex-space contract: incoming vertices are ALWAYS in the component node's + local space; UIMesh owns applying the node's world matrix: + * not batched -- on the GPU, via the USE_LOCAL macro + the per-draw + cc-local UBO the 2D batcher already maintains for middleware draws + (web: DrawBatch.useLocalData; native: RenderEntity useLocal bit); + * batched -- on the CPU, baked into the chunk copy right here, so + draws still merge (a merged batch has no per-draw matrix). + Either way the caller never touches USE_LOCAL / useLocalData / + RenderEntity.setUseLocal -- those legacy switches are derived internally. + There is no "already world-baked" mode: consumers feed node-local data and + never apply the node transform themselves. + + Opacity contract: UIMesh data is FillColorType.VERTEX (vertex color is used + as-is), so the batcher does not apply cascaded node opacity. UIMesh itself + multiplies the cascaded opacity (own color alpha x every ancestor's + UIOpacity-driven localOpacity) into the copied vertices every frame. + `premultipliedAlpha` declares the data format: straight data fades through + the alpha byte only; premultiplied data scales light RGBA + dark RGB so the + blend result stays linear in opacity. +*/ + +import { JSB } from 'internal:constants'; +import { ccclass, editable, serializable } from 'cc.decorator'; +import { errorID } from '../../core'; +import { UIRenderer } from '../framework/ui-renderer'; +import { RenderData } from '../renderer/render-data'; +import { RenderDrawInfo, RenderDrawInfoType } from '../renderer/render-draw-info'; +import { StaticVBAccessor } from '../renderer/static-vb-accessor'; +import type { Batcher2D } from '../renderer/batcher-2d'; +import { vfmtPosUvColor4B, vfmtPosUvTwoColor4B, getAttributeStride } from '../renderer/vertex-format'; +import { RenderEntity, RenderEntityType } from '../renderer/render-entity'; +import { director } from '../../game'; +import { Texture2D } from '../../asset/assets'; +import { builtinResMgr } from '../../asset/asset-manager'; +import { BlendFactor } from '../../gfx'; +import type { MeshBuffer } from '../renderer/mesh-buffer'; +import type { MaterialInstance } from '../../render-scene'; +import type { Material } from '../../asset/assets/material'; +import type { Node } from '../../scene-graph'; + +/** + * @en A segment of the mesh: a range of indices drawn with one texture+material. + * @zh 网格的一个片段:一段索引,用同一纹理+材质绘制。 + */ +export interface UIMeshSegment { + indexOffset: number; + indexCount: number; + texture: Texture2D | null; + material: MaterialInstance | null; +} + +/** + * @en Pre-baked mesh data for one frame. + * @zh 一帧的预烘焙网格数据。 + * vertexStride: 24 (single-color V3F_T2F_C4B) or 28 (two-color V3F_T2F_C4B_C4B). + */ +export interface UIMeshData { + vertexCount: number; + vertexStride: number; + vertexData: Uint8Array; + indexCount: number; + indexData: Uint8Array; + segments: UIMeshSegment[]; +} + +// Shared static vertex-buffer accessors, isolated per Batcher2D instance. +// Batcher2D.destroy() destroys every accessor registered with it, so whenever +// the current batcher changes (a root destroyed and rebuilt within the same JS +// context) the cache is invalidated: a destroyed accessor must never be reused, +// and every new accessor must be registered with the new batcher or its +// buffers would never be uploaded or reset. +let _accessorBatcher: Batcher2D | null = null; +let _sharedAccessor: StaticVBAccessor | null = null; +let _sharedTintAccessor: StaticVBAccessor | null = null; + +// Registration keys in the batcher's accessor map (base-36 namespaced, the +// same convention as the built-in middleware assemblers — spine/dragon-bones). +const UIMESH_ACCESSOR_KEY = Number.parseInt('UIMESH', 36); +const UIMESH_TINT_ACCESSOR_KEY = Number.parseInt('UIMESHTINT', 36); + +// The two vertex formats UIMesh consumes; strides derived from the format +// definitions themselves (24 = V3F_T2F_C4B, 28 = V3F_T2F_C4B_C4B). Anything +// else would desync the byte math from the allocated chunk's format. +const MESH_STRIDE_BYTES = getAttributeStride(vfmtPosUvColor4B); +const MESH_STRIDE_TINT_BYTES = getAttributeStride(vfmtPosUvTwoColor4B); + +/** + * @en A generic 2D mesh renderer that consumes pre-baked vertex/index data. + * The data provider (e.g. a spine plugin) fills setMeshData every frame; this + * component handles buffer allocation, batching and submission. + * @zh 通用 2D 网格渲染器,消费预烘焙的顶点/索引数据。数据提供方(如 spine 插件) + * 每帧调用 setMeshData,本组件负责缓冲分配、合批与提交。 + */ +@ccclass('cc.UIMesh') +export class UIMesh extends UIRenderer { + @serializable + protected _enableBatch = false; + + /** + * @en Whether the incoming color data is premultiplied-alpha. Declares how + * cascaded opacity is folded into the vertices (alpha byte only vs + * RGBA + dark RGB) and the blend factors of the builtin material. + * @zh 输入颜色数据是否为预乘 alpha 格式。决定级联不透明度折算方式 + * (仅 alpha 字节 vs RGBA + dark RGB)及内置材质混合因子。 + */ + @serializable + protected _premultipliedAlpha = false; + + protected _meshData: UIMeshData | null = null; + protected _useTint = false; + private _drawInfoList: RenderDrawInfo[] = []; + + // JSB staleness poll cache (see update): the opacity / world-affine values + // captured by the last _prepareBuffers run. Drift means the vertex bytes + // (baked transform / folded opacity) no longer match the node state. + private _pollOpacity = 1; + private _pollM00 = 1; + private _pollM01 = 0; + private _pollM04 = 0; + private _pollM05 = 1; + private _pollM12 = 0; + private _pollM13 = 0; + + constructor () { + super(); + this._useVertexOpacity = true; + } + + /** + * @en Feeds the pre-baked mesh data for the current frame. + * @zh 喂入当前帧的预烘焙网格数据。 + */ + public setMeshData (data: UIMeshData): void { + // Boundary validation: bad input is reported here, at the call site, + // instead of corrupting shared vertex buffers or throwing mid-render. + // A rejected frame keeps the last accepted mesh. + if (!this._validateMeshData(data)) return; + const useTint = data.vertexStride === MESH_STRIDE_TINT_BYTES; + if (useTint !== this._useTint) { + this.destroyRenderData(); + this._useTint = useTint; + this._flushAssembler(); + // The builtin fallback material follows the stride (TWO_COLORED). + this.updateMaterial(); + } + this._meshData = data; + this._markForUpdateRenderData(); + } + + private _validateMeshData (data: UIMeshData): boolean { + const vc = data.vertexCount; + const ic = data.indexCount; + if (data.vertexStride !== MESH_STRIDE_BYTES && data.vertexStride !== MESH_STRIDE_TINT_BYTES) { + errorID(9010, data.vertexStride, MESH_STRIDE_BYTES, MESH_STRIDE_TINT_BYTES); + return false; + } + if (vc < 0 || ic < 0) { + errorID(9011, vc, ic); + return false; + } + // Capacity limits come from the accessor that will serve the data. + const accessor = this.ensureAccessor(data.vertexStride === MESH_STRIDE_TINT_BYTES); + if (vc > accessor.maxVertexCount || ic > accessor.maxIndexCount) { + errorID(9016, vc, ic, accessor.maxVertexCount, accessor.maxIndexCount); + return false; + } + if (!data.vertexData || data.vertexData.byteLength < vc * data.vertexStride) { + errorID(9012, data.vertexData ? data.vertexData.byteLength : 0, vc, data.vertexStride); + return false; + } + if (!data.indexData || data.indexData.byteOffset % 2 !== 0 || data.indexData.byteLength < ic * 2) { + errorID(9013, data.indexData ? data.indexData.byteLength : 0, + data.indexData ? data.indexData.byteOffset : 0, ic, ic * 2); + return false; + } + // Indices are mesh-local; anything >= vertexCount would sample vertices + // of other chunks in the shared buffer (silent geometry corruption). + const indices = new Uint16Array(data.indexData.buffer, data.indexData.byteOffset, ic); + for (let i = 0; i < ic; ++i) { + if (indices[i] >= vc) { + errorID(9014, i, indices[i], vc); + return false; + } + } + for (let i = 0; i < data.segments.length; ++i) { + const seg = data.segments[i]; + if (seg.indexOffset < 0 || seg.indexCount < 0 || seg.indexOffset + seg.indexCount > ic) { + errorID(9015, i, seg.indexOffset, seg.indexOffset + seg.indexCount, ic); + return false; + } + } + return true; + } + + /** + * @en Whether to enable sprite batching. + * @zh 是否启用合批。 + */ + @editable + get enableBatch (): boolean { return this._enableBatch; } + set enableBatch (value: boolean) { + this._enableBatch = value; + this._syncTransformMode(); + this.updateMaterial(); + } + + /** + * @en Whether the input color data is premultiplied-alpha. + * @zh 输入颜色数据是否为预乘 alpha 格式。 + */ + @editable + get premultipliedAlpha (): boolean { return this._premultipliedAlpha; } + set premultipliedAlpha (value: boolean) { + this._premultipliedAlpha = value; + this.updateMaterial(); + this._markForUpdateRenderData(); + this.onPremultipliedAlphaChanged(); + } + + /** + * Notifies subclasses that the declared data format changed. The property + * itself is owned here (single source of truth); data producers — e.g. a + * spine plugin whose C++ side premultiplies vertex colors — override this + * to forward the format to their baker instead of redeclaring the field. + */ + protected onPremultipliedAlphaChanged (): void {} + + public onLoad (): void { + super.onLoad(); + // Deserialization has applied _enableBatch by now; the derived + // legacy-switch state may differ from the constructor default. + this._syncTransformMode(); + } + + /** + * Derives every legacy transform switch (vertices are always node-local). + * Invariant: RenderEntity useLocal == "vertices are still node-local at + * submit time". Batching bakes the world transform on the JS + * side (_prepareBuffers), so by submit time that data is world-space and + * the entity must report local = false — on native, middleware draws only + * merge when !useLocal, so useLocal is simply !enableBatch: only a merged + * batch carries world-baked vertices (baked by _prepareBuffers). + */ + private _syncTransformMode (): void { + this._renderEntity.setUseLocal(!this._enableBatch); + this._markForUpdateRenderData(); + } + + protected _updateColor (): void { + super._updateColor(); + // VERTEX-type data has no assembler color pass; the opacity multiply in + // _prepareBuffers reads this._color, so a change must re-prepare. + this._markForUpdateRenderData(); + } + + /** + * JSB staleness poll. The native draw-info path re-runs _prepareBuffers + * (which bakes the world transform and folds cascaded opacity into the + * vertex bytes) only when this renderer is marked dirty — and neither node + * transforms nor UIOpacity mark middleware renderers (TRANSFORM_CHANGED + * fires only on the changed node, UIOpacity writes localOpacity with no + * event). Poll the captured inputs and re-mark on drift. Web runs + * _prepareBuffers unconditionally through fillBuffers, so skip there. + * Subclasses overriding update should call super.update(dt). + */ + public update (dt: number): void { + if (!JSB || !this._renderData || !this._meshData) return; + if (this._enableBatch) { + const m = this.node.worldMatrix; + if (m.m00 !== this._pollM00 || m.m01 !== this._pollM01 || m.m04 !== this._pollM04 + || m.m05 !== this._pollM05 || m.m12 !== this._pollM12 || m.m13 !== this._pollM13) { + this._markForUpdateRenderData(); + return; + } + } + if (this._computeCascadedOpacity() !== this._pollOpacity) { + this._markForUpdateRenderData(); + } + } + + protected _flushAssembler (): void { + if (this._renderData === null) { + const accessor = this.ensureAccessor(this._useTint); + this._renderData = RenderData.add(this._useTint ? vfmtPosUvTwoColor4B : vfmtPosUvColor4B, accessor); + } + } + + public override updateRenderer (): void { + super.updateRenderer(); + if (!JSB) return; + if (this._renderFlag) { + this._prepareNativeDrawInfos(); + } else { + this._renderEntity.clearDynamicRenderDrawInfos(); + } + } + + protected _render (batcher: any): void { + const prepared = this._prepareBuffers(); + if (!prepared || !this._meshData) return; + const { meshBuffer, startIndex } = prepared; + const data = this._meshData; + + // Commit each segment with its texture + material. A segment without + // its own material falls back to the component material (the builtin + // spine effect, kept in step with the declared vertex space). + for (const seg of data.segments) { + const mat = seg.material || this.getRenderMaterial(0); + if (seg.texture && mat) { + batcher.commitMiddleware(this, meshBuffer, startIndex + seg.indexOffset, seg.indexCount, + seg.texture, mat, this._enableBatch); + } + } + } + + private _prepareBuffers (): { meshBuffer: MeshBuffer, startIndex: number } | null { + if (!this._renderData || !this._meshData) return null; + const data = this._meshData; + const rd = this._renderData; + const vc = data.vertexCount; + const ic = data.indexCount; + if (vc < 1 || ic < 1) return null; + const vLength = vc * data.vertexStride; + + // Ensure the render data buffers are large enough. The reserved capacity + // keeps ~10% head room but is clamped to the accessor's per-chunk caps: + // allocateChunk rejects anything above them, so an unclamped reserve + // would permanently fail data that actually fits (30000 vertices -> + // 33000 requested > 32767). The actual per-frame counts are tracked on + // the render data; the reserve lives in the chunk (vb bytes, indexCount). + if (!rd.chunk || rd.chunk.vb.byteLength < vLength || rd.chunk.indexCount < ic) { + rd.resize( + Math.min(Math.ceil(vc * 1.1), rd.accessor.maxVertexCount), + Math.min(Math.ceil(ic * 1.1), rd.accessor.maxIndexCount), + ); + if (!rd.chunk) { + errorID(9017, vc, ic, rd.accessor.maxVertexCount, rd.accessor.maxIndexCount); + return null; + } + rd.updateSize(vc, ic); + } else if (rd.vertexCount !== vc || rd.indexCount !== ic) { + rd.updateSize(vc, ic); + } + if (!rd.chunk) return null; + // Copy vertex data into the chunk's vertex view (a view of the shared + // vData at the chunk's vertexOffset). + const vbuf = rd.chunk.vb; + const vU8 = new Uint8Array(vbuf.buffer, vbuf.byteOffset, vLength); + vU8.set(data.vertexData.subarray(0, vLength)); + + // Node-local vertices + batched mode: a merged batch carries no + // per-draw matrix, so the node's world transform is baked into the + // positions right here. Only the chunk copy is touched — the incoming + // vertexData may be a view of memory shared with a worker. + if (this._enableBatch) { + this._bakeWorldTransform(vbuf, vLength, data.vertexStride); + } + + // Cascaded opacity: VERTEX-type data gets no engine-side fade, UIMesh + // applies it while copying (see the opacity contract in the header). + const opacity = this._computeCascadedOpacity(); + if (opacity < 0.9999) { + this._applyOpacity(vU8, vc, data.vertexStride, opacity); + } + + // Capture the inputs consumed above for the JSB staleness poll (update). + this._pollOpacity = opacity; + if (this._enableBatch) { + const m = this.node.worldMatrix; + this._pollM00 = m.m00; this._pollM01 = m.m01; this._pollM04 = m.m04; + this._pollM05 = m.m05; this._pollM12 = m.m12; this._pollM13 = m.m13; + } + + // Offset the indices by the chunk's vertexOffset and append them into + // the shared index buffer. appendIndices grows the buffer as needed and + // advances meshBuffer.indexOffset; commitMiddleware reads meshBuffer.iData. + const meshBuffer = rd.getMeshBuffer()!; + // The native batcher resets its mesh-buffer offset through the shared + // memory view after uploading. Synchronize the JS-side cached value + // before appending this frame's indices. + if (JSB) meshBuffer.indexOffset = meshBuffer.sharedBuffer[2]; + const startIndex = meshBuffer.indexOffset; + const chunkOffset = rd.chunk.vertexOffset; + const offsetIndices = new Uint16Array(ic); + new Uint8Array(offsetIndices.buffer).set(data.indexData.subarray(0, ic * 2)); + for (let i = 0; i < ic; i++) offsetIndices[i] += chunkOffset; + rd.chunk.vertexAccessor.appendIndices(rd.chunk.bufferId, offsetIndices); + + if (vc > 0 || ic > 0) rd.chunk.vertexAccessor.getMeshBuffer(rd.chunk.bufferId).setDirty(); + return { meshBuffer, startIndex }; + } + + /** + * Bakes the node's world matrix into the chunk copy's positions (2D affine + * of the world matrix, column-major: x' = m00*x + m04*y + m12). Skipped + * when the matrix is the identity. Runs every frame so node movement is + * picked up with no dirty tracking. + */ + private _bakeWorldTransform (vbuf: Float32Array, byteLength: number, stride: number): void { + const m = this.node.worldMatrix; + const m00 = m.m00; const m01 = m.m01; const m04 = m.m04; const m05 = m.m05; + const m12 = m.m12; const m13 = m.m13; + if (m00 === 1 && m01 === 0 && m04 === 0 && m05 === 1 && m12 === 0 && m13 === 0) return; + const floats = new Float32Array(vbuf.buffer, vbuf.byteOffset, byteLength >> 2); + const strideF = stride >> 2; + const vertexCount = byteLength / stride; + for (let i = 0; i < vertexCount; i++) { + const o = i * strideF; + const x = floats[o]; + const y = floats[o + 1]; + floats[o] = m00 * x + m04 * y + m12; + floats[o + 1] = m01 * x + m05 * y + m13; + } + } + + /** + * Cascaded opacity as the batcher would compute it for COLOR-type data: + * own color alpha x every ancestor's localOpacity (what UIOpacity writes). + */ + private _computeCascadedOpacity (): number { + let opacity = this._color.a / 255; + for (let node: Node | null = this.node; node; node = node.parent) { + opacity *= node._uiProps.localOpacity; + } + return opacity; + } + + /** + * Folds opacity into the copied vertices. Layout: pos*3f + uv*2f puts the + * light color at bytes 20..23; two-color data carries dark at 24..27. + * Straight data fades through alpha only (SRC_ALPHA blending scales the + * whole source at blend time); premultiplied data scales light RGBA and + * dark RGB (dark alpha is the PMA sentinel, not a fade channel) so the + * blend result stays linear in opacity. + */ + private _applyOpacity (vU8: Uint8Array, vc: number, stride: number, opacity: number): void { + const pma = this._premultipliedAlpha; + const darkRGB = (pma && stride === MESH_STRIDE_TINT_BYTES) ? 3 : 0; + for (let i = 0; i < vc; i++) { + const o = i * stride + 20; + if (pma) { + vU8[o] = (vU8[o] * opacity + 0.5) | 0; + vU8[o + 1] = (vU8[o + 1] * opacity + 0.5) | 0; + vU8[o + 2] = (vU8[o + 2] * opacity + 0.5) | 0; + } + vU8[o + 3] = (vU8[o + 3] * opacity + 0.5) | 0; + for (let j = 0; j < darkRGB; j++) { + vU8[o + 4 + j] = (vU8[o + 4 + j] * opacity + 0.5) | 0; + } + } + } + + private _prepareNativeDrawInfos (): void { + this._renderEntity.clearDynamicRenderDrawInfos(); + const prepared = this._prepareBuffers(); + const data = this._meshData; + const rd = this._renderData; + if (!prepared || !data || !rd?.chunk) return; + + const { startIndex } = prepared; + let drawIndex = 0; + for (const seg of data.segments) { + const mat = seg.material || this.getRenderMaterial(0); + if (!seg.texture || !mat) continue; + let drawInfo = this._drawInfoList[drawIndex]; + if (!drawInfo) { + drawInfo = new RenderDrawInfo(); + drawInfo.setDrawInfoType(RenderDrawInfoType.MIDDLEWARE); + this._drawInfoList[drawIndex] = drawInfo; + } + drawInfo.setAccAndBuffer(rd.accessor.id, rd.chunk.bufferId); + drawInfo.setIndexOffset(startIndex + seg.indexOffset); + drawInfo.setIBCount(seg.indexCount); + drawInfo.setTexture(seg.texture.getGFXTexture()); + drawInfo.setSampler(seg.texture.getGFXSampler()); + drawInfo.setMaterial(mat); + this._renderEntity.setDynamicRenderDrawInfo(drawInfo, drawIndex); + drawIndex++; + } + } + + /** + * The builtin material is the spine effect (default-spine-material): the + * stock ui-sprite-material has no USE_LOCAL variant, so node-local input + * through the component material would render stuck at the origin. The + * spine effect carries both USE_LOCAL and TWO_COLORED macros; segment + * materials supplied via setMeshData are the provider's own contract. + */ + protected _updateBuiltinMaterial (): Material { + return builtinResMgr.get('default-spine-material'); + } + + /** + * Keeps the builtin material instance in step with the declared vertex + * space / data format: USE_LOCAL for the GPU take-over mode, TWO_COLORED + * for two-color data, and blend factors matching the alpha format. + */ + public updateMaterial (): void { + // Align the legacy blend-factor fields first so _updateBlendFunc + // (inside super) never fights the explicit patching below. + this._srcBlendFactor = this._premultipliedAlpha ? BlendFactor.ONE : BlendFactor.SRC_ALPHA; + this._dstBlendFactor = BlendFactor.ONE_MINUS_SRC_ALPHA; + super.updateMaterial(); + if (this._customMaterial) return; // custom materials are the user's contract + const inst = this.getMaterialInstance(0); + if (!inst) return; + inst.recompileShaders({ + USE_LOCAL: !this._enableBatch, + TWO_COLORED: this._useTint, + }); + // Straight data: src_alpha / 1-src_alpha (the effect default); + // premultiplied data: ONE / 1-src_alpha, color and alpha channels. + const src = this._premultipliedAlpha ? BlendFactor.ONE : BlendFactor.SRC_ALPHA; + const pass = inst.passes[0]; + const target = pass.blendState.targets[0]; + target.blend = true; + target.blendSrc = src; + target.blendSrcAlpha = src; + target.blendDst = BlendFactor.ONE_MINUS_SRC_ALPHA; + target.blendDstAlpha = BlendFactor.ONE_MINUS_SRC_ALPHA; + pass.blendState.setTarget(0, target); + pass._updatePassHash(); + } + + protected createRenderEntity (): RenderEntity { + const entity = new RenderEntity(RenderEntityType.DYNAMIC); + // Vertices are always node-local: useLocal mirrors "not batched" and is + // re-derived in onLoad/_syncTransformMode once deserialization has run. + entity.setUseLocal(!this._enableBatch); + return entity; + } + + private ensureAccessor (useTint: boolean): StaticVBAccessor { + const batcher = director.root!.batcher2D; + // Invalidate the shared cache when the batcher changed: the previous + // batcher destroyed its accessors together with itself (Root.destroy), + // so a cached one would be dead and unregistered. + if (_accessorBatcher !== batcher) { + _accessorBatcher = batcher; + _sharedAccessor = null; + _sharedTintAccessor = null; + } + let accessor = useTint ? _sharedTintAccessor : _sharedAccessor; + if (!accessor) { + const device = director.root!.device; + // Copy the format: the accessor empties its attributes array on + // destroy, and the vfmt* constants are shared module-level state. + const attributes = (useTint ? vfmtPosUvTwoColor4B : vfmtPosUvColor4B).slice(); + // 32767 matches the engine's spine middleware assembler (Simple.vCount, + // cocos/spine/assembler/simple.ts): the established per-chunk cap for + // middleware meshes, half the Uint16 index space. + accessor = new StaticVBAccessor(device, attributes, 32767); + // Registration makes the batcher upload/reset the accessor every + // frame and destroy it together with itself. + batcher.registerBufferAccessor(useTint ? UIMESH_TINT_ACCESSOR_KEY : UIMESH_ACCESSOR_KEY, accessor); + if (useTint) { + _sharedTintAccessor = accessor; + } else { + _sharedAccessor = accessor; + } + } + return accessor; + } +} diff --git a/cocos/2d/renderer/static-vb-accessor.ts b/cocos/2d/renderer/static-vb-accessor.ts index 7773a7d0b8..cdd6522797 100644 --- a/cocos/2d/renderer/static-vb-accessor.ts +++ b/cocos/2d/renderer/static-vb-accessor.ts @@ -85,6 +85,12 @@ export class StaticVBAccessor extends BufferAccessor { private _id = 0; get id (): number { return this._id; } + /** Per-chunk capacity; allocateChunk refuses requests above these. */ + get maxVertexCount (): number { return this._vCount; } + + /** Per-chunk index capacity; allocateChunk refuses requests above these. */ + get maxIndexCount (): number { return this._iCount; } + public constructor (device: Device, attributes: Attribute[], vCount?: number, iCount?: number) { super(device, attributes); this._vCount = vCount || Math.floor(macro.BATCHER2D_MEM_INCREMENT * 1024 / this._vertexFormatBytes); diff --git a/tests/ui/ui-mesh.test.ts b/tests/ui/ui-mesh.test.ts new file mode 100644 index 0000000000..b139854746 --- /dev/null +++ b/tests/ui/ui-mesh.test.ts @@ -0,0 +1,215 @@ +import { UIMesh, UIMeshData } from '../../cocos/2d/components/ui-mesh'; +import { UITransform } from '../../cocos/2d'; +import { Node } from '../../cocos/scene-graph/node'; +import { Scene, director } from '../../exports/base'; +import { Batcher2D } from '../../cocos/2d/renderer/batcher-2d'; +import { vfmtPosUvColor4B, vfmtPosUvTwoColor4B } from '../../cocos/2d/renderer/vertex-format'; +import { captureErrorIDs } from '../utils/log-capture'; + +// Mirrors sprite.test.ts: a headless batcher so UIMesh can create its shared +// StaticVBAccessor and allocate chunks from it. +// @ts-expect-error +director.root!._batcher = new Batcher2D(director.root!); +const scene = new Scene('uimesh-test'); +director.runSceneImmediate(scene); + +const ACCESSOR_VERTEX_COUNT = 32767; + +function createUIMesh (): { mesh: UIMesh, anyMesh: any } { + const node = new Node('mesh'); + node.addComponent(UITransform); + scene.addChild(node); + const mesh = node.addComponent(UIMesh); + return { mesh, anyMesh: mesh as any }; +} + +function makeMeshData (vc: number, stride = 24): UIMeshData { + const quadCount = Math.floor(vc / 4); + const ic = quadCount * 6; + const vertexData = new Uint8Array(vc * stride); + const indices = new Uint16Array(ic); + for (let q = 0; q < quadCount; ++q) { + const v = q * 4; + const o = q * 6; + indices[o] = v; indices[o + 1] = v + 1; indices[o + 2] = v + 2; + indices[o + 3] = v + 2; indices[o + 4] = v + 1; indices[o + 5] = v + 3; + } + return { + vertexCount: vc, + vertexStride: stride, + vertexData, + indexCount: ic, + indexData: new Uint8Array(indices.buffer), + segments: [{ indexOffset: 0, indexCount: ic, texture: null, material: null }], + }; +} + +describe('UIMesh: accessor capacity', () => { + test('30000 vertices render — the 10% reserve is clamped to the accessor cap', () => { + const { mesh, anyMesh } = createUIMesh(); + const data = makeMeshData(30000); // fits, but ceil(30000 * 1.1) > 32767 + mesh.setMeshData(data); + expect(anyMesh._meshData).toBe(data); + expect(anyMesh._prepareBuffers()).not.toBeNull(); + const rd = anyMesh._renderData; + // Actual counts are tracked; the reserve lives in the chunk. + expect(rd.vertexCount).toBe(30000); + expect(rd.indexCount).toBe(data.indexCount); + expect(rd.chunk).toBeTruthy(); + expect(rd.chunk.vb.byteLength).toBe(ACCESSOR_VERTEX_COUNT * 24); + }); + + test('32767 vertices (exact accessor cap) render', () => { + const { mesh, anyMesh } = createUIMesh(); + mesh.setMeshData(makeMeshData(32767)); + expect(anyMesh._prepareBuffers()).not.toBeNull(); + const rd = anyMesh._renderData; + expect(rd.vertexCount).toBe(32767); + expect(rd.chunk.vb.byteLength).toBe(ACCESSOR_VERTEX_COUNT * 24); + }); + + test('data beyond the accessor cap is rejected with an error', () => { + const { mesh, anyMesh } = createUIMesh(); + const watcher = captureErrorIDs(); + mesh.setMeshData(makeMeshData(32768)); + expect(watcher.captured).toHaveLength(1); + expect(watcher.captured[0]).toEqual([9016, 32768, 49152, 32767, 131068]); + watcher.clear(); + expect(anyMesh._meshData).toBeNull(); // rejected frame stores nothing + }); + + test('growth within the reserve does not reallocate the chunk', () => { + const { mesh, anyMesh } = createUIMesh(); + mesh.setMeshData(makeMeshData(100)); + expect(anyMesh._prepareBuffers()).not.toBeNull(); + const chunk = anyMesh._renderData.chunk; + mesh.setMeshData(makeMeshData(105)); // within the ~10% head room + expect(anyMesh._prepareBuffers()).not.toBeNull(); + expect(anyMesh._renderData.chunk).toBe(chunk); + expect(anyMesh._renderData.vertexCount).toBe(105); + }); +}); + +describe('UIMesh: setMeshData boundary validation', () => { + let mesh: UIMesh; + let anyMesh: any; + let watcher: ReturnType; + + beforeEach(() => { + ({ mesh, anyMesh } = createUIMesh()); + watcher = captureErrorIDs(); + }); + + // The watcher must be empty by test end (log-capture afterEach enforces it): + // every test asserts its single errorID call and clears it. Entries are the + // raw errorID arguments — [id, ...params]; the texts live in DebugInfos.json. + afterEach(() => { + watcher.clear(); + }); + + test('rejects unsupported vertex stride', () => { + mesh.setMeshData(makeMeshData(8, 32)); + expect(watcher.captured).toHaveLength(1); + expect(watcher.captured[0]).toEqual([9010, 32, 24, 28]); + expect(anyMesh._meshData).toBeNull(); + }); + + test('rejects vertexData shorter than vertexCount * stride', () => { + const data = makeMeshData(8); + data.vertexData = data.vertexData.slice(0, data.vertexData.byteLength - 1); + mesh.setMeshData(data); + expect(watcher.captured).toHaveLength(1); + expect(watcher.captured[0]).toEqual([9012, 191, 8, 24]); + expect(anyMesh._meshData).toBeNull(); + }); + + test('rejects indexData shorter than indexCount * 2', () => { + const data = makeMeshData(8); + data.indexData = data.indexData.slice(0, data.indexData.byteLength - 2); + mesh.setMeshData(data); + expect(watcher.captured).toHaveLength(1); + expect(watcher.captured[0]).toEqual([9013, 22, 0, 12, 24]); + expect(anyMesh._meshData).toBeNull(); + }); + + test('rejects indices out of vertex range', () => { + const data = makeMeshData(8); + const indices = new Uint16Array(data.indexData.buffer, data.indexData.byteOffset, data.indexCount); + indices[0] = data.vertexCount; // one past the last vertex + mesh.setMeshData(data); + expect(watcher.captured).toHaveLength(1); + expect(watcher.captured[0]).toEqual([9014, 0, 8, 8]); + expect(anyMesh._meshData).toBeNull(); + }); + + test('rejects segment ranges beyond indexCount', () => { + const data = makeMeshData(8); + data.segments = [{ indexOffset: 0, indexCount: data.indexCount + 1, texture: null, material: null }]; + mesh.setMeshData(data); + expect(watcher.captured).toHaveLength(1); + expect(watcher.captured[0]).toEqual([9015, 0, 0, 13, 12]); + expect(anyMesh._meshData).toBeNull(); + }); + + test('a rejected frame keeps the last accepted mesh', () => { + const good = makeMeshData(8); + mesh.setMeshData(good); + mesh.setMeshData(makeMeshData(32768)); // over cap, rejected + expect(watcher.captured).toHaveLength(1); + expect(watcher.captured[0]).toEqual([9016, 32768, 49152, 32767, 131068]); + expect(anyMesh._meshData).toBe(good); + }); +}); + +describe('UIMesh: accessor lifetime follows the batcher', () => { + test('a rebuilt root/batcher never reuses a destroyed accessor (single- and two-color)', () => { + const UIMESH_KEY = Number.parseInt('UIMESH', 36); + const UIMESH_TINT_KEY = Number.parseInt('UIMESHTINT', 36); + + // First root: a UIMesh creates its accessors and registers them with + // the current batcher. + const first = createUIMesh(); + first.mesh.setMeshData(makeMeshData(8)); + expect(first.anyMesh._prepareBuffers()).not.toBeNull(); + const oldAccessor = first.anyMesh._renderData.accessor; + const oldMap: Map = (director.root!.batcher2D as any)._bufferAccessors; + expect(oldMap.get(UIMESH_KEY)).toBe(oldAccessor); // registered with its batcher + + // Root.destroy(): the batcher destroys every accessor it owns, is + // dropped, and a rebuilt root creates a fresh batcher with an empty map + // — all within the same JS context. + const root: any = director.root!; + const oldBatcher: Batcher2D = root.batcher2D; + oldBatcher.destroy(); + expect(oldMap.size).toBe(0); // accessors released with the batcher + expect((oldAccessor as any)._buffers.length).toBe(0); // buffers gone + root._batcher = new Batcher2D(root); + + // UIMesh components under the new root must resolve fresh accessors + // from the new batcher. A batcher-keyed cache that forgot to invalidate + // would hand back the destroyed accessor and skip registration, + // breaking upload/reset. + const second = createUIMesh(); + second.mesh.setMeshData(makeMeshData(8)); + expect(second.anyMesh._prepareBuffers()).not.toBeNull(); + const newAccessor = second.anyMesh._renderData.accessor; + expect(newAccessor).not.toBe(oldAccessor); + expect((newAccessor as any)._buffers.length).toBeGreaterThan(0); + + const newMap: Map = (director.root!.batcher2D as any)._bufferAccessors; + expect(newMap.get(UIMESH_KEY)).toBe(newAccessor); // registered with the new batcher + + // Two-color branch: same guarantee for the tint accessor. + const tinted = createUIMesh(); + tinted.mesh.setMeshData(makeMeshData(8, 28)); + expect(tinted.anyMesh._prepareBuffers()).not.toBeNull(); + const newTintAccessor = tinted.anyMesh._renderData.accessor; + expect(newTintAccessor).not.toBe(oldAccessor); + expect(newMap.get(UIMESH_TINT_KEY)).toBe(newTintAccessor); + + // The accessors hold copies of the vertex formats: destroying them with + // the old batcher must not empty the shared module-level constants. + expect(vfmtPosUvColor4B).toHaveLength(3); + expect(vfmtPosUvTwoColor4B).toHaveLength(4); + }); +}); From 7377aecb22eaa54607d32f43a13d3e6a4dec5208 Mon Sep 17 00:00:00 2001 From: Developer Date: Mon, 17 Aug 2026 16:12:25 +0800 Subject: [PATCH 2/4] Disable built-in spine by default; minor editor/build fixes - Default the built-in Spine feature (spine-3.8) to off in feature cropping, so it is only included when enabled in the editor. - Recognize scale as a valid spine atlas page attribute in the texture inspector. - Skip incomplete platform dirs when bundling runtime adapters. Co-Authored-By: Claude (cherry picked from commit 7f7922aaf42ff1857f0a6d38970a75d303032f57) --- editor/engine-features/render-config.json | 2 +- editor/inspector/assets/texture/parse-atlas.js | 2 +- scripts/build-adapter.js | 8 +++++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/editor/engine-features/render-config.json b/editor/engine-features/render-config.json index d2b25cf063..893a2b16db 100644 --- a/editor/engine-features/render-config.json +++ b/editor/engine-features/render-config.json @@ -397,7 +397,7 @@ "spine-3.8": { "enginePlugin": true, "cmakeConfig": "USE_SPINE_3_8", - "default": true, + "default": false, "isNativeModule": true, "label": "i18n:ENGINE.features.spine_38.label", "description": "i18n:ENGINE.features.spine_38.description", diff --git a/editor/inspector/assets/texture/parse-atlas.js b/editor/inspector/assets/texture/parse-atlas.js index e843f55ce3..3aa331b3ff 100644 --- a/editor/inspector/assets/texture/parse-atlas.js +++ b/editor/inspector/assets/texture/parse-atlas.js @@ -42,7 +42,7 @@ function formatValue(value) { } const imageExt = ['.png', '.jpg', '.jpeg', '.webp', '.gif']; -const pageAttr = ['name', 'size', 'format', 'filter', 'repeat', 'pma']; +const pageAttr = ['name', 'size', 'format', 'filter', 'repeat', 'scale', 'pma']; class ParseAtlasFile { constructor() { } diff --git a/scripts/build-adapter.js b/scripts/build-adapter.js index 1d0666d92e..7fcd9f765c 100644 --- a/scripts/build-adapter.js +++ b/scripts/build-adapter.js @@ -78,9 +78,15 @@ async function bundleRuntimeAdapter () { const platforms = getPlatformsFromPath(platformsPath); console.log(green(`\nBundling runtime platform adapters, including: ${platforms}`)); for (const platform of platforms) { + const engineEntry = normalizePath(ps.join(engineRoot, `platforms/runtime/platforms/${platform}/engine/index.js`)); + // Some platform dirs may be incomplete in a checkout (missing engine files); + // skip them instead of crashing the adapter build. + if (!fs.existsSync(engineEntry)) { + console.log(`skip platform: ${platform} (missing ${engineEntry})`); + continue; + } console.log(`handle platform: ${green(platform)}`); // bundle engine-adapter.js - const engineEntry = normalizePath(ps.join(engineRoot, `platforms/runtime/platforms/${platform}/engine/index.js`)); const engineOutput = normalizePath(ps.join(engineRoot, `bin/adapter/runtime/${platform}/engine-adapter.js`)); await bundle(engineEntry, engineOutput, true); } From 9b088245b552f9e9a8b8b760006daf794e3b2b34 Mon Sep 17 00:00:00 2001 From: Developer Date: Mon, 17 Aug 2026 16:12:25 +0800 Subject: [PATCH 3/4] Export WebAssembly interface under cc.wasm namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add exports/webassembly.ts re-exporting pal/wasm (instantiateWasm, fetchBuffer, fetchUrl, ensureWasmModuleReady) as the public cc.wasm namespace, so extensions/game code can load their own .wasm files through the engine's platform-adaptive path. - Expose it as a croppable feature rather than an unconditional export from exports/base.ts: cc.config.json declares a `webassembly` feature and editor/engine-features/render-config.json adds the panel entry (default on, required), so it appears under Project Settings -> Feature Cropping. The label/description are plain text on purpose — i18n:ENGINE.* keys live in the editor package and a custom engine cannot add them. (cherry picked from commit 92003ab4a21b6ee753cf3ba2ca0f4767658e9cdc) Co-Authored-By: Claude Opus 5 --- cc.config.json | 3 ++ editor/engine-features/render-config.json | 7 +++ exports/webassembly.ts | 52 +++++++++++++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 exports/webassembly.ts diff --git a/cc.config.json b/cc.config.json index a0549df564..4b28d7d921 100644 --- a/cc.config.json +++ b/cc.config.json @@ -300,6 +300,9 @@ "overrideConstants": { "CULL_MESHOPT": false } + }, + "webassembly": { + "modules": ["webassembly"] } }, "moduleOverrides": [{ diff --git a/editor/engine-features/render-config.json b/editor/engine-features/render-config.json index 893a2b16db..2af48c5733 100644 --- a/editor/engine-features/render-config.json +++ b/editor/engine-features/render-config.json @@ -489,6 +489,13 @@ "description": "i18n:ENGINE.features.xr.description", "enginePlugin": false, "envCondition": "$NATIVE || $HTML5" + }, + "webassembly": { + "default": true, + "required": true, + "label": "WebAssembly", + "description": "Export the engine's cross-platform WebAssembly loading interface as cc.wasm, so that extensions and game scripts can load their own .wasm files. 以 cc.wasm 导出引擎的跨平台 WebAssembly 加载接口,供扩展与游戏脚本加载自己的 .wasm 文件。", + "enginePlugin": true } }, "categories": { diff --git a/exports/webassembly.ts b/exports/webassembly.ts new file mode 100644 index 0000000000..781629741f --- /dev/null +++ b/exports/webassembly.ts @@ -0,0 +1,52 @@ +/* + Copyright (c) 2026 Xiamen Yaji Software Co., Ltd. + + https://www.cocos.com/ + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is furnished + to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +*/ + +import { instantiateWasm, fetchBuffer, fetchUrl, ensureWasmModuleReady } from 'pal/wasm'; + +/** + * @en + * The engine's packaged cross-platform WebAssembly interface (pal/wasm). + * + * Re-exported under the public `cc.wasm` namespace so extension/game code can + * load its own `.wasm` files through the same platform-adaptive path the engine + * uses internally for box2d / physx / spine / webgpu: + * + * - web: fetch the `.wasm` bytes, then `WebAssembly.instantiate`; + * - mini-game: resolve the path into `cocos-js/` and delegate to the platform's + * `CCWebAssembly.instantiate` (which accepts a file path, never + * raw bytes — this is why embedded-base64 wasm fails there); + * - native: read the file from `src/cocos-js/` via `fileUtils`. + * + * The `wasmUrl` argument is a bare file name (e.g. `'foo.wasm'`) whose file is + * expected to land in the build output's `cocos-js/` directory. + * @zh + * 引擎封装好的跨平台 WebAssembly 接口(pal/wasm),通过 `cc.wasm` 命名空间公开, + * 供扩展/游戏代码用与引擎内部一致的路径加载自己的 `.wasm`。 + */ +export const wasm = { + instantiateWasm, + fetchBuffer, + fetchUrl, + ensureWasmModuleReady, +}; From 571737757458b2497c9b11e41b4ceacf78cf4d0b Mon Sep 17 00:00:00 2001 From: Engineer Date: Wed, 30 Sep 2026 10:44:18 +0800 Subject: [PATCH 4/4] feat(simulator): generic native plugin discovery opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generic plugin discovery chain (cc_gen_plugin_cmake_hook -> plugins_parser.js -> Pre-AutoLoadPlulgins.cmake -> find_package -> plugin_registry) already exists and is what native device builds use; the simulator never invoked it. Wire it in behind an opt-in cache variable, with no plugin names or plugin logic in the engine — the plugin set is always just the scan result: - templates/cmake/common.cmake: cc_gen_plugin_cmake_hook()'s scan roots (CC_PLUGIN_PATH) may now be pre-set by the caller; the native-build project-layout defaults are unchanged when nobody pre-defines it - simulator runtime-src CMakeLists.txt: -DSIMULATOR_PLUGIN_SCAN_DIRS=" [;]" scans those roots for cc_plugin.json through the same chain as device builds (CC_PROJECT_DIR points into the build tree so the generated hook lands there, satisfying the parser's output-dir invariant); unset keeps the stock plugin-less simulator, and a cache variable means plain `cmake --build` reuses the configured scan - simulator Game.cpp: call cc_load_all_plugins() after init, mirroring BaseGame::init(), so registered plugins' JSB bindings exist before any script runs (no-op when no plugins are linked) - libsimulator protobuf config.h: skip the / defines on MSVC >= 1950 (headers removed); stubs fall back to the std::map emulation path Verified end-to-end: a project extensions/ tree scanned at configure time produces a simulator exe whose plugins register their JSB bindings at startup (observed live in the simulator's JS context). Co-Authored-By: Claude Code --- .../frameworks/runtime-src/CMakeLists.txt | 18 ++++++++++++++++++ .../frameworks/runtime-src/Classes/Game.cpp | 3 +++ .../libsimulator/lib/protobuf-lite/config.h | 7 +++++++ templates/cmake/common.cmake | 15 ++++++++++----- 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/native/tools/simulator/frameworks/runtime-src/CMakeLists.txt b/native/tools/simulator/frameworks/runtime-src/CMakeLists.txt index e05aa92c7e..be16d44e17 100644 --- a/native/tools/simulator/frameworks/runtime-src/CMakeLists.txt +++ b/native/tools/simulator/frameworks/runtime-src/CMakeLists.txt @@ -43,6 +43,19 @@ set(USE_DEBUG_RENDERER OFF) include(${cocosdir}/CMakeLists.txt) include(${cocosdir}/tools/simulator/libsimulator/CMakeLists.txt) +# Native plugin discovery, same mechanism as native builds. Opt-in: pass +# -DSIMULATOR_PLUGIN_SCAN_DIRS="[;]" with directories to scan for +# cc_plugin.json. Unset (default) keeps the stock plugin-less simulator. +set(SIMULATOR_PLUGIN_SCAN_DIRS "" CACHE STRING + "Directories scanned for cc_plugin.json native plugins") +set(COCOS_X_PATH ${cocosdir}) +if(SIMULATOR_PLUGIN_SCAN_DIRS) + set(CC_PROJECT_DIR ${CMAKE_BINARY_DIR}/plugin-scan) + set(CC_PLUGIN_PATH ${SIMULATOR_PLUGIN_SCAN_DIRS}) + file(MAKE_DIRECTORY ${CC_PROJECT_DIR}) + cc_common_before_target(${APP_NAME}) +endif() + if(MACOSX) @@ -153,6 +166,11 @@ else() endif() target_link_libraries(${LIB_NAME} ${ENGINE_NAME} simulator) +# Plain signature, matching the call above: mixing keyword/plain forms for one +# target is a hard CMake error. +if(TARGET plugin_registry) + target_link_libraries(${LIB_NAME} plugin_registry) +endif() target_include_directories(${LIB_NAME} PRIVATE Classes ) diff --git a/native/tools/simulator/frameworks/runtime-src/Classes/Game.cpp b/native/tools/simulator/frameworks/runtime-src/Classes/Game.cpp index 137e6ff956..e1fc7e6da2 100644 --- a/native/tools/simulator/frameworks/runtime-src/Classes/Game.cpp +++ b/native/tools/simulator/frameworks/runtime-src/Classes/Game.cpp @@ -25,6 +25,7 @@ #include "Game.h" #include "cocos/application/ApplicationManager.h" #include "cocos/bindings/jswrapper/SeApi.h" +#include "cocos/plugins/Plugins.h" #include "cocos/bindings/manual/jsb_classtype.h" #include "cocos/bindings/manual/jsb_global.h" #include "cocos/bindings/manual/jsb_module_register.h" @@ -56,6 +57,8 @@ Game::~Game() { int Game::init() { cc::pipeline::GlobalDSManager::setDescriptorSetLayout(); + // Mirrors BaseGame::init(): register native plugins before any script runs. + cc_load_all_plugins(); SimulatorApp::getInstance()->init(); std::call_once(_windowCreateFlag, [&]() { cc::ISystemWindowInfo info; diff --git a/native/tools/simulator/libsimulator/lib/protobuf-lite/config.h b/native/tools/simulator/libsimulator/lib/protobuf-lite/config.h index c1df8dd4d0..307355ebe8 100755 --- a/native/tools/simulator/libsimulator/lib/protobuf-lite/config.h +++ b/native/tools/simulator/libsimulator/lib/protobuf-lite/config.h @@ -30,6 +30,11 @@ /* protobuf config.h for MSVC. On other platforms, this is generated * automatically by autoheader / autoconf / configure. */ +/* VS 18 (MSVC >= 1950) removed the legacy / headers. + * Leave the HAVE_* flags undefined there so stubs/hash.h takes its built-in + * MISSING_HASH fallback (std::map / std::set based). */ +#if !(defined(_MSC_VER) && _MSC_VER >= 1950) + /* the location of */ #define HASH_MAP_H @@ -53,6 +58,8 @@ /* define if the compiler has hash_set */ #define HAVE_HASH_SET 1 +#endif // MSVC >= 1950 + /* define if you want to use zlib. See readme.txt for additional * requirements. */ // #define HAVE_ZLIB 1 diff --git a/templates/cmake/common.cmake b/templates/cmake/common.cmake index f113836f31..f44849a4ba 100644 --- a/templates/cmake/common.cmake +++ b/templates/cmake/common.cmake @@ -60,14 +60,19 @@ endfunction() function(cc_gen_plugin_cmake_hook) - set(project_root_dir ${CC_PROJECT_DIR}/../../..) set(load_plugin_cmake ${CC_PROJECT_DIR}/Pre-AutoLoadPlulgins.cmake) message(STATUS "Try generating ${load_plugin_cmake}") file(REMOVE ${load_plugin_cmake}) - list(APPEND CC_PLUGIN_PATH - ${project_root_dir}/native - ${project_root_dir}/extensions - ) + # Scan roots default to the native-build project layout; callers that host + # the scan elsewhere (e.g. the Simulator, pointed at an arbitrary + # extensions tree) may pre-set CC_PLUGIN_PATH with their own roots. + if(NOT DEFINED CC_PLUGIN_PATH) + set(project_root_dir ${CC_PROJECT_DIR}/../../..) + list(APPEND CC_PLUGIN_PATH + ${project_root_dir}/native + ${project_root_dir}/extensions + ) + endif() set(plugin_args_info ${CMAKE_CURRENT_BINARY_DIR}/plugin_dirs.txt) file(WRITE ${plugin_args_info} "# directories for searching native plugins\n") foreach(dir ${CC_PLUGIN_PATH})