Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cc.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,9 @@
"overrideConstants": {
"CULL_MESHOPT": false
}
},
"webassembly": {
"modules": ["webassembly"]
}
},
"moduleOverrides": [{
Expand Down
1 change: 1 addition & 0 deletions cocos/2d/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
241 changes: 241 additions & 0 deletions cocos/2d/components/ui-mesh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
/*
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.
*/

import { ccclass, editable, serializable } from 'cc.decorator';
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 { vfmtPosUvColor4B, vfmtPosUvTwoColor4B } from '../renderer/vertex-format';
import { RenderEntity, RenderEntityType } from '../renderer/render-entity';
import { director } from '../../game';
import { Texture2D } from '../../asset/assets';
import { JSB } from 'internal:constants';

Check failure on line 19 in cocos/2d/components/ui-mesh.ts

View workflow job for this annotation

GitHub Actions / Run ESLint

`internal:constants` import should occur before import of `../framework/ui-renderer`
import type { MeshBuffer } from '../renderer/mesh-buffer';
import type { MaterialInstance } from '../../render-scene';

/**
* @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. All UIMesh components share one
// accessor (per vertex format) so the 2D batcher resets its buffer each frame;
// per-component accessors registered under the same key would overwrite each
// other in the batcher's map and never get reset (indexOffset accumulates).
let _sharedAccessor: StaticVBAccessor | null = null;
let _sharedTintAccessor: StaticVBAccessor | null = null;

/**
* @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;

protected _meshData: UIMeshData | null = null;
protected _useTint = false;
protected _accessor: StaticVBAccessor | null = null;
protected _tintAccessor: StaticVBAccessor | null = null;
private _drawInfoList: RenderDrawInfo[] = [];

constructor () {
super();
this._useVertexOpacity = true;
}

/**
* @en Feeds the pre-baked mesh data for the current frame.
* @zh 喂入当前帧的预烘焙网格数据。
*/
public setMeshData (data: UIMeshData): void {
const useTint = data.vertexStride === 28;
if (useTint !== this._useTint) {
this.destroyRenderData();
this._useTint = useTint;
this._flushAssembler();
}
this._meshData = data;
this._markForUpdateRenderData();
}

/**
* @en Whether to enable sprite batching.
* @zh 是否启用合批。
*/
@editable
get enableBatch (): boolean { return this._enableBatch; }
set enableBatch (value: boolean) {
this._enableBatch = value;
this._renderEntity.setUseLocal(!value);
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.
for (const seg of data.segments) {
if (seg.texture && seg.material) {
batcher.commitMiddleware(this, meshBuffer, startIndex + seg.indexOffset, seg.indexCount,

Check failure on line 131 in cocos/2d/components/ui-mesh.ts

View workflow job for this annotation

GitHub Actions / Run ESLint

There should be no line break here

Check failure on line 131 in cocos/2d/components/ui-mesh.ts

View workflow job for this annotation

GitHub Actions / Run ESLint

Expected newline between arguments/params

Check failure on line 131 in cocos/2d/components/ui-mesh.ts

View workflow job for this annotation

GitHub Actions / Run ESLint

Expected newline between arguments/params

Check failure on line 131 in cocos/2d/components/ui-mesh.ts

View workflow job for this annotation

GitHub Actions / Run ESLint

Expected newline between arguments/params

Check failure on line 131 in cocos/2d/components/ui-mesh.ts

View workflow job for this annotation

GitHub Actions / Run ESLint

Expected newline after '('
seg.texture, seg.material, this._enableBatch);

Check failure on line 132 in cocos/2d/components/ui-mesh.ts

View workflow job for this annotation

GitHub Actions / Run ESLint

Expected newline before ')'

Check failure on line 132 in cocos/2d/components/ui-mesh.ts

View workflow job for this annotation

GitHub Actions / Run ESLint

Expected newline between arguments/params

Check failure on line 132 in cocos/2d/components/ui-mesh.ts

View workflow job for this annotation

GitHub Actions / Run ESLint

Expected newline between arguments/params

Check failure on line 132 in cocos/2d/components/ui-mesh.ts

View workflow job for this annotation

GitHub Actions / Run ESLint

Expected indentation of 20 spaces but found 41
}
}
}

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.
if (rd.vertexCount !== vc || rd.indexCount !== ic) {
if (!rd.chunk || rd.chunk.vb.byteLength < vLength || rd.chunk.indexCount < ic) {
rd.resize(Math.ceil(vc * 1.1), Math.ceil(ic * 1.1));
} else {
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));

// 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 };
}

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) {
if (!seg.texture || !seg.material) 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(seg.material);
this._renderEntity.setDynamicRenderDrawInfo(drawInfo, drawIndex);
drawIndex++;
}
}

protected createRenderEntity (): RenderEntity {
const entity = new RenderEntity(RenderEntityType.DYNAMIC);
entity.setUseLocal(true);
return entity;
}

private ensureAccessor (useTint: boolean): StaticVBAccessor {
let accessor = useTint ? this._tintAccessor : this._accessor;
if (!accessor) {
const device = director.root!.device;
const batcher = director.root!.batcher2D;
const attributes = useTint ? vfmtPosUvTwoColor4B : vfmtPosUvColor4B;
if (useTint) {
if (!_sharedTintAccessor) {
_sharedTintAccessor = new StaticVBAccessor(device, attributes, 32767);
batcher.registerBufferAccessor(Number.parseInt('UIMESHTINT', 36), _sharedTintAccessor);
}
accessor = _sharedTintAccessor;
} else {
if (!_sharedAccessor) {
_sharedAccessor = new StaticVBAccessor(device, attributes, 32767);
batcher.registerBufferAccessor(Number.parseInt('UIMESH', 36), _sharedAccessor);
}
accessor = _sharedAccessor;
}
if (useTint) {
this._tintAccessor = accessor;
} else {
this._accessor = accessor;
}
}
return accessor;
}
}
9 changes: 8 additions & 1 deletion editor/engine-features/render-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion editor/inspector/assets/texture/parse-atlas.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() { }
Expand Down
52 changes: 52 additions & 0 deletions exports/webassembly.ts
Original file line number Diff line number Diff line change
@@ -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,
};
8 changes: 7 additions & 1 deletion scripts/build-adapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Loading