Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/core/src/2d/text/TextRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,11 @@ export class TextRenderer extends Renderer implements ITextRenderer {
if (charLength > 0) {
this._buildChunk(curTextChunk, charLength);
}
// _buildChunk 经 _freeTextChunks + allocateSubChunk 拿到的可能是别人释放的 slot,
// 该 slot 内存里 pos 字段是上一任 owner 的残留;_buildChunk 只写 UV/color 不写 pos,
// 所以必须强制 WorldPosition dirty 让下游 _updatePosition 重写 pos。
// 否则 bounds getter 路径会因 _setDirtyFlagFalse(Font) 一并清掉脏位,pos 残留被渲染。
this._setDirtyFlagTrue(DirtyFlag.WorldPosition);
charRenderInfos.length = 0;
}

Expand Down
5 changes: 5 additions & 0 deletions packages/ui/src/component/advanced/Text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,11 @@ export class Text extends UIRenderer implements ITextRenderer {
if (charLength > 0) {
this._buildChunk(curTextChunk, charLength);
}
// _buildChunk 经 _freeTextChunks + allocateSubChunk 拿到的可能是别人释放的 slot,
// 该 slot 内存里 pos 字段是上一任 owner 的残留;_buildChunk 只写 UV/color 不写 pos,
// 所以必须强制 WorldPosition dirty 让下游 _updatePosition 重写 pos。
// 否则 bounds getter 路径会因 _setDirtyFlagFalse(Font) 一并清掉脏位,pos 残留被渲染。
this._setDirtyFlagTrue(DirtyFlag.WorldPosition);
charRenderInfos.length = 0;
}

Expand Down
120 changes: 120 additions & 0 deletions tests/src/core/2d/text/TextRenderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,3 +397,123 @@ describe("TextRenderer", () => {
engine.destroy();
});
});

// TextRenderer 内部 dirty flag 数值(与 packages/core/src/2d/text/TextRenderer.ts 中 enum DirtyFlag 保持一致)
const TR_DIRTY_LOCAL_POSITION_BOUNDS = 0x2;
const TR_DIRTY_WORLD_POSITION = 0x4;

/** 读取 TextRenderer 当前所有 chunk 的 vertex pos 字段(每 vertex 9 floats,pos 在 +0,+1,+2)。*/
function readTextRendererPosFloats(text: any): number[] {
const result: number[] = [];
for (const chunk of text._textChunks) {
const subChunk = chunk.subChunk;
if (!subChunk) continue;
const vertices = subChunk.chunk.vertices;
let vo = subChunk.vertexArea.start;
const numVerts = subChunk.vertexArea.size / 9;
for (let i = 0; i < numVerts; i++, vo += 9) {
result.push(vertices[vo + 0], vertices[vo + 1], vertices[vo + 2]);
}
}
return result;
}

/**
* 回归测试:bounds getter 路径下 _updateLocalData 重新分配 vertex slot 的 dirty flag 契约。
* 与 UI Text 同名测试同一根因 —— TextRenderer 当前所有 setter 都用 DirtyFlag.Position(含 WorldPosition),
* 视觉上不会触发;本测试用于守住契约,防未来新增"只点 LocalPositionBounds"的路径埋雷。
*/
describe("TextRenderer - bounds-getter slot residue regression", () => {
let engine: WebGLEngine;
let rootEntity: Entity;

beforeAll(async function () {
engine = await WebGLEngine.create({ canvas: document.createElement("canvas") });
rootEntity = engine.sceneManager.activeScene.createRootEntity("regression-root");
const cameraEntity = rootEntity.createChild("Camera");
cameraEntity.addComponent(Camera);
});

it("_updateLocalData must leave WorldPosition dirty so downstream _updatePosition runs", () => {
const e = rootEntity.createChild("dirty-flag-invariant-tr");
const t = e.addComponent(TextRenderer);
t.text = "AB";
// 触发首次 _updateLocalData + _updatePosition,让 dirty flag 完全消化
void t.bounds;

// 清空所有 dirty flag
(t as any)._dirtyFlag = 0;

// 直接调用 _updateLocalData
(t as any)._updateLocalData();

// 修复契约:_updateLocalData 退出时 WorldPosition 必须 dirty
expect((t as any)._dirtyFlag & TR_DIRTY_WORLD_POSITION).to.eq(TR_DIRTY_WORLD_POSITION);
});

it("bounds getter must re-write vertex pos when only LocalPositionBounds is dirty (corrupted-slot)", () => {
const e = rootEntity.createChild("bounds-getter-rewrite-tr");
const t = e.addComponent(TextRenderer);
t.text = "CD";
e.transform.setPosition(1.23, -0.45, 0);
void t.bounds;

const before = readTextRendererPosFloats(t);
expect(before.length).to.be.greaterThan(0);

// 把 slot 内存里的 pos 字段人为改成"残留垃圾"
const chunks = (t as any)._textChunks;
const v = chunks[0].subChunk.chunk.vertices;
let vo = chunks[0].subChunk.vertexArea.start;
const numVerts = chunks[0].subChunk.vertexArea.size / 9;
for (let i = 0; i < numVerts; i++, vo += 9) {
v[vo + 0] = 99999;
v[vo + 1] = 99999;
v[vo + 2] = 99999;
}

// 仅点 LocalPositionBounds(模拟未来可能新增的"只点 LocalPositionBounds"路径)
(t as any)._dirtyFlag = TR_DIRTY_LOCAL_POSITION_BOUNDS;

void t.bounds;

const after = readTextRendererPosFloats(t);
expect(after.length).to.eq(before.length);
for (let i = 0; i < after.length; i++) {
expect(after[i]).to.be.closeTo(before[i], 0.001);
}
});

it("vertex pos remains correct after sibling chunk is destroyed (full slot-reuse repro)", () => {
const leadEntity = rootEntity.createChild("repro-lead-tr");
const lead = leadEntity.addComponent(TextRenderer);
leadEntity.transform.setPosition(-3, -1, 0);
lead.text = "ab";
void lead.bounds;

const stableEntity = rootEntity.createChild("repro-stable-tr");
const stable = stableEntity.addComponent(TextRenderer);
stableEntity.transform.setPosition(0.5, 1, 0);
stable.text = "cd";
void stable.bounds;

const before = readTextRendererPosFloats(stable);
expect(before.length).to.be.greaterThan(0);

leadEntity.destroy();

(stable as any)._dirtyFlag = TR_DIRTY_LOCAL_POSITION_BOUNDS;

void stable.bounds;

const after = readTextRendererPosFloats(stable);
expect(after.length).to.eq(before.length);
for (let i = 0; i < after.length; i++) {
expect(after[i]).to.be.closeTo(before[i], 0.001);
}
});

afterAll(() => {
engine.destroy();
});
});
143 changes: 141 additions & 2 deletions tests/src/ui/Text.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,27 @@
import { Font, WebGLEngine } from "@galacean/engine";
import { Text, UITransform } from "@galacean/engine-ui";
import { Camera, Font, WebGLEngine } from "@galacean/engine";
import { CanvasRenderMode, Text, UICanvas, UITransform } from "@galacean/engine-ui";
import { describe, expect, it } from "vitest";

// Text 内部 dirty flag 数值(与 packages/ui/src/component/advanced/Text.ts 中 enum DirtyFlag 保持一致)
const DIRTY_LOCAL_POSITION_BOUNDS = 0x8;
const DIRTY_WORLD_POSITION = 0x10;

/** 读取 Text 当前所有 chunk 的 vertex pos 字段(每 vertex 9 floats,pos 在 +0,+1,+2)。*/
function readTextPosFloats(text: any): number[] {
const result: number[] = [];
for (const chunk of text._textChunks) {
const subChunk = chunk.subChunk;
if (!subChunk) continue;
const vertices = subChunk.chunk.vertices;
let vo = subChunk.vertexArea.start;
const numVerts = subChunk.vertexArea.size / 9;
for (let i = 0; i < numVerts; i++, vo += 9) {
result.push(vertices[vo + 0], vertices[vo + 1], vertices[vo + 2]);
}
}
return result;
}

describe("Text", async () => {
const canvas = document.createElement("canvas");
const engine = await WebGLEngine.create({ canvas: canvas });
Expand Down Expand Up @@ -134,3 +154,122 @@ describe("Text", async () => {
expect(cloneText.font).to.eq(text.font);
});
});

/**
* 回归测试:bounds getter 路径下 _updateLocalData 重新分配 vertex slot 的 dirty flag 契约。
*
* Bug 根因:bounds getter 顺序检查 LocalPositionBounds → 调 _updateLocalData(内部 _freeTextChunks +
* allocateSubChunk 可能拿到别人释放的 slot,slot 内存里 pos 是上一任残留),然后检查 WorldPosition
* → 但 _updateLocalData 没把 WorldPosition 点亮 → _updatePosition 跳过 → 末尾 _setDirtyFlagFalse(Font)
* 把所有 dirty 一并清空 → 下一帧 _render 也不重写 pos → GPU 渲染残留 pos(视觉错位/缺字)。
*
* 修复:_updateLocalData 末尾强制 _setDirtyFlagTrue(WorldPosition),让 bounds getter 的下一行检查命中。
*/
describe("Text - bounds-getter slot residue regression", async () => {
const canvas = document.createElement("canvas");
const engine = await WebGLEngine.create({ canvas });

@augmentcode augmentcode Bot Apr 30, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tests/src/ui/Text.test.ts:170: This new regression suite creates a WebGLEngine but never calls engine.destroy(), and this file now creates two engines total. That can leak WebGL contexts across tests and make CI runs flaky due to context/resource exhaustion.

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

const webCanvas = engine.canvas;
webCanvas.width = 750;
webCanvas.height = 1334;
const scene = engine.sceneManager.scenes[0];
const root = scene.createRootEntity("regression-root");

const cameraEntity = root.createChild("Camera");
cameraEntity.addComponent(Camera);

const uiCanvasEntity = root.createChild("UICanvas");
const uiCanvas = uiCanvasEntity.addComponent(UICanvas);
uiCanvas.renderMode = CanvasRenderMode.ScreenSpaceOverlay;
uiCanvas.referenceResolutionPerUnit = 100;

it("_updateLocalData must leave WorldPosition dirty so downstream _updatePosition runs", () => {
const e = uiCanvasEntity.createChild("dirty-flag-invariant");
const t = e.addComponent(Text);
t.text = "AB";
// 触发首次 _updateLocalData + _updatePosition,让 dirty flag 完全消化
void t.bounds;

// 清空所有 dirty flag
(t as any)._dirtyUpdateFlag = 0;

// 直接调用 _updateLocalData
(t as any)._updateLocalData();

// 修复契约:_updateLocalData 退出时 WorldPosition 必须 dirty
// (因为 _buildChunk 内 allocateSubChunk 可能拿到含别人 pos 残留的 slot,
// pos 字段必须由后续 _updatePosition 重写)
expect((t as any)._dirtyUpdateFlag & DIRTY_WORLD_POSITION).to.eq(DIRTY_WORLD_POSITION);
});

it("bounds getter must re-write vertex pos when only LocalPositionBounds is dirty (corrupted-slot)", () => {
const e = uiCanvasEntity.createChild("bounds-getter-rewrite");
const t = e.addComponent(Text);
t.text = "CD";
(e.transform as UITransform).setPosition(123, -45, 0);
void t.bounds;

const before = readTextPosFloats(t);
expect(before.length).to.be.greaterThan(0);

// 把 slot 内存里的 pos 字段人为改成"残留垃圾",模拟前一任 owner 留下的脏数据
const chunks = (t as any)._textChunks;
const v = chunks[0].subChunk.chunk.vertices;
let vo = chunks[0].subChunk.vertexArea.start;
const numVerts = chunks[0].subChunk.vertexArea.size / 9;
for (let i = 0; i < numVerts; i++, vo += 9) {
v[vo + 0] = 99999;
v[vo + 1] = 99999;
v[vo + 2] = 99999;
}

// 仅点 LocalPositionBounds(模拟 _onRootCanvasModify(ReferenceResolutionPerUnit))
(t as any)._dirtyUpdateFlag = DIRTY_LOCAL_POSITION_BOUNDS;

// 走 bounds getter 路径
void t.bounds;

// 修复后:bounds getter 内 _updateLocalData → 设 WorldPosition dirty → _updatePosition 重写 pos
const after = readTextPosFloats(t);
expect(after.length).to.eq(before.length);
for (let i = 0; i < after.length; i++) {
expect(after[i]).to.be.closeTo(before[i], 0.001);
}
});

it("vertex pos remains correct after sibling chunk is destroyed (full slot-reuse repro)", () => {
// 模拟末日打僵尸 weapon panel 切 tab 场景:
// 1. Lead Text 先创建,占低 offset slot
// 2. Stable Text 后创建,占次低 offset slot
// 3. 销毁 Lead → 释放低 offset slot(free list 头部出现 Lead 的旧 slot)
// 4. 仅点 Stable 的 LocalPositionBounds,触发 bounds getter
// → _updateLocalData free Stable 自己 slot + 合并 + 重 alloc 拿到 Lead 旧 slot(first-fit)
// → 修复关闭:_updatePosition 跳过 → 渲染 Lead 残留的 pos → bug
// → 修复开启:_updatePosition 重写 → 渲染 Stable 自己的 pos
const leadEntity = uiCanvasEntity.createChild("repro-lead");
const lead = leadEntity.addComponent(Text);
(leadEntity.transform as UITransform).setPosition(-300, -100, 0);
lead.text = "ab";
void lead.bounds;

const stableEntity = uiCanvasEntity.createChild("repro-stable");
const stable = stableEntity.addComponent(Text);
(stableEntity.transform as UITransform).setPosition(50, 100, 0);
stable.text = "cd";
void stable.bounds;

const before = readTextPosFloats(stable);
expect(before.length).to.be.greaterThan(0);

leadEntity.destroy();

(stable as any)._dirtyUpdateFlag = DIRTY_LOCAL_POSITION_BOUNDS;

void stable.bounds;

const after = readTextPosFloats(stable);
expect(after.length).to.eq(before.length);
for (let i = 0; i < after.length; i++) {
expect(after[i]).to.be.closeTo(before[i], 0.001);
}
});
});
Loading