Skip to content

Commit 32ffbde

Browse files
committed
✨ 文本渲染缓存
1 parent 9137f63 commit 32ffbde

File tree

17 files changed

+204
-110
lines changed

17 files changed

+204
-110
lines changed

app/src/core/Project.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,16 +221,21 @@ export class Project {
221221
}
222222
}
223223
}
224-
this.loop();
225224
}
226225

227-
private loop() {
226+
loop() {
227+
if (this.rafHandle !== -1) return;
228228
const animationFrame = () => {
229229
this.tick();
230230
this.rafHandle = requestAnimationFrame(animationFrame.bind(this));
231231
};
232232
animationFrame();
233233
}
234+
pause() {
235+
if (this.rafHandle === -1) return;
236+
cancelAnimationFrame(this.rafHandle);
237+
this.rafHandle = -1;
238+
}
234239
private tick() {
235240
for (const service of this.tickableServices) {
236241
try {

app/src/core/render/canvas2d/basicRenderer/textRenderer.tsx

Lines changed: 145 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Color, LruCache, Vector } from "@graphif/data-structures";
22
import md5 from "md5";
3-
import { FONT, replaceTextWhenProtect } from "../../../../utils/font";
3+
import { FONT, getTextSize, replaceTextWhenProtect } from "../../../../utils/font";
44
import { Project, service } from "../../../Project";
55
import { Settings } from "../../../service/Settings";
66

@@ -15,98 +15,125 @@ export class TextRenderer {
1515

1616
constructor(private readonly project: Project) {}
1717

18-
private hash(text: string, fontSize: number, width: number): string {
19-
// md5(text)_fontSize_width
18+
private hash(text: string, size: number): string {
19+
// md5(text)_fontSize
2020
const textHash = md5(text);
21-
return `${textHash}_${fontSize}_${width}`;
21+
return `${textHash}_${size}`;
2222
}
23-
private getCache(text: string, fontSize: number, width: number) {
24-
const cacheKey = this.hash(text, fontSize, width);
23+
private getCache(text: string, size: number) {
24+
const cacheKey = this.hash(text, size);
2525
const cacheValue = this.cache.get(cacheKey);
2626
return cacheValue;
2727
}
2828
/**
29-
* 获取text和width相同,fontSize最接近的缓存图片
29+
* 获取text相同,fontSize最接近的缓存图片
3030
*/
31-
/**
32-
* 获取 text 和 width 相同,fontSize 最接近的缓存图片
33-
*/
34-
private getCacheNearestSize(text: string, fontSize: number, width: number): ImageBitmap | undefined {
35-
let best: ImageBitmap | undefined = undefined;
36-
let minDelta = Infinity;
37-
for (const key of this.cache.keys()) {
38-
const parts = key.split("_");
39-
if (parts.length !== 3) continue;
31+
private getCacheNearestSize(text: string, size: number): ImageBitmap | undefined {
32+
const textHash = md5(text);
33+
let nearestBitmap: ImageBitmap | undefined;
34+
let minDiff = Infinity;
4035

41-
const [textHash, cachedFontSizeStr, cachedWidthStr] = parts;
36+
// 遍历缓存中所有key
37+
for (const key of this.cache.keys()) {
38+
// 解构出textHash和fontSize
39+
const [cachedTextHash, cachedFontSizeStr] = key.split("_");
4240
const cachedFontSize = Number(cachedFontSizeStr);
43-
const cachedWidth = Number(cachedWidthStr);
4441

45-
if (textHash === md5(text) && cachedWidth === width) {
46-
const delta = Math.abs(cachedFontSize - fontSize);
47-
if (delta < minDelta) {
48-
minDelta = delta;
49-
best = this.cache.get(key);
42+
// 只处理相同text的缓存
43+
if (cachedTextHash === textHash) {
44+
const diff = Math.abs(cachedFontSize - size);
45+
if (diff < minDiff) {
46+
minDiff = diff;
47+
nearestBitmap = this.cache.get(key);
5048
}
5149
}
5250
}
5351

54-
return best;
52+
return nearestBitmap;
53+
}
54+
55+
private buildCache(text: string, size: number, color: Color) {
56+
const textSize = getTextSize(text, size);
57+
const canvas = new OffscreenCanvas(textSize.x, textSize.y);
58+
const ctx = canvas.getContext("2d")!;
59+
ctx.textBaseline = "middle";
60+
ctx.textAlign = "left";
61+
ctx.font = `${size}px normal ${FONT}`;
62+
ctx.fillStyle = color.toString();
63+
ctx.fillText(text, 0, size / 2);
64+
createImageBitmap(canvas).then((bmp) => {
65+
const cacheKey = this.hash(text, size);
66+
this.cache.set(cacheKey, bmp);
67+
// console.log("[TextRenderer] 缓存已建立 %s", cacheKey);
68+
});
69+
return canvas;
5570
}
5671

5772
/**
5873
* 从左上角画文本
59-
* @param text
60-
* @param location
61-
* @param fontSize
62-
* @param color
6374
*/
64-
renderOneLineText(text: string, location: Vector, fontSize: number, color: Color = Color.White): void {
65-
// alphabetic, top, hanging, middle, ideographic, bottom
75+
renderText(text: string, location: Vector, size: number, color: Color = Color.White): void {
76+
if (text.trim().length === 0) return;
77+
text = Settings.sync.protectingPrivacy ? replaceTextWhenProtect(text) : text;
78+
// 如果有缓存,直接渲染
79+
const cache = this.getCache(text, size);
80+
if (cache) {
81+
this.project.canvas.ctx.drawImage(cache, location.x, location.y);
82+
return;
83+
}
84+
if (Settings.sync.textScalingBehavior !== "cacheEveryTick") {
85+
// 如果摄像机正在缩放,就找到大小最接近的缓存图片,然后位图缩放
86+
const currentScale = this.project.camera.currentScale.toFixed(2);
87+
const targetScale = this.project.camera.targetScale.toFixed(2);
88+
if (currentScale !== targetScale) {
89+
if (Settings.sync.textScalingBehavior === "nearestCache") {
90+
// 文字应该渲染成什么大小
91+
const textSize = getTextSize(text, size);
92+
const nearestBitmap = this.getCacheNearestSize(text, size);
93+
// console.log("[TextRenderer] 缩放状态下 (%f -> %f),使用缓存图片 %o", currentScale, targetScale, nearestBitmap);
94+
if (nearestBitmap) {
95+
this.project.canvas.ctx.drawImage(
96+
nearestBitmap,
97+
location.x,
98+
location.y,
99+
Math.round(textSize.x),
100+
Math.round(textSize.y),
101+
);
102+
return;
103+
}
104+
} else if (Settings.sync.textScalingBehavior === "temp") {
105+
this.renderTempText(text, location, size, color);
106+
return;
107+
}
108+
}
109+
}
110+
this.project.canvas.ctx.drawImage(this.buildCache(text, size, color), location.x, location.y);
111+
}
112+
/**
113+
* 渲染临时文字,不构建缓存,不使用缓存
114+
*/
115+
renderTempText(text: string, location: Vector, size: number, color: Color = Color.White): void {
116+
if (text.trim().length === 0) return;
66117
text = Settings.sync.protectingPrivacy ? replaceTextWhenProtect(text) : text;
67118
this.project.canvas.ctx.textBaseline = "middle";
68119
this.project.canvas.ctx.textAlign = "left";
69-
if (Settings.sync.textIntegerLocationAndSizeRender) {
70-
this.project.canvas.ctx.font = `${Math.round(fontSize)}px ${FONT}`;
71-
} else {
72-
this.project.canvas.ctx.font = `${fontSize}px normal ${FONT}`;
73-
}
120+
this.project.canvas.ctx.font = `${size}px normal ${FONT}`;
74121
this.project.canvas.ctx.fillStyle = color.toString();
75-
if (Settings.sync.textIntegerLocationAndSizeRender) {
76-
this.project.canvas.ctx.fillText(text, Math.floor(location.x), Math.floor(location.y + fontSize / 2));
77-
} else {
78-
this.project.canvas.ctx.fillText(text, location.x, location.y + fontSize / 2);
79-
}
122+
this.project.canvas.ctx.fillText(text, location.x, location.y + size / 2);
80123
}
81124

82125
/**
83126
* 从中心位置开始绘制文本
84-
* @param text
85-
* @param centerLocation
86-
* @param size
87-
* @param color
88-
* @param shadowColor
89127
*/
90128
renderTextFromCenter(text: string, centerLocation: Vector, size: number, color: Color = Color.White): void {
91-
text = Settings.sync.protectingPrivacy ? replaceTextWhenProtect(text) : text;
92-
this.project.canvas.ctx.textBaseline = "middle";
93-
this.project.canvas.ctx.textAlign = "center";
94-
if (Settings.sync.textIntegerLocationAndSizeRender) {
95-
this.project.canvas.ctx.font = `${Math.round(size)}px normal ${FONT}`;
96-
} else {
97-
this.project.canvas.ctx.font = `${size}px normal ${FONT}`;
98-
}
99-
this.project.canvas.ctx.fillStyle = color.toString();
100-
if (Settings.sync.textIntegerLocationAndSizeRender) {
101-
this.project.canvas.ctx.fillText(text, Math.floor(centerLocation.x), Math.floor(centerLocation.y));
102-
} else {
103-
this.project.canvas.ctx.fillText(text, centerLocation.x, centerLocation.y);
104-
}
105-
// 重置阴影
106-
this.project.canvas.ctx.shadowBlur = 0; // 阴影模糊程度
107-
this.project.canvas.ctx.shadowOffsetX = 0; // 水平偏移
108-
this.project.canvas.ctx.shadowOffsetY = 0; // 垂直偏移
109-
this.project.canvas.ctx.shadowColor = "none";
129+
if (text.trim().length === 0) return;
130+
const textSize = getTextSize(text, size);
131+
this.renderText(text, centerLocation.subtract(textSize.divide(2)), size, color);
132+
}
133+
renderTempTextFromCenter(text: string, centerLocation: Vector, size: number, color: Color = Color.White): void {
134+
if (text.trim().length === 0) return;
135+
const textSize = getTextSize(text, size);
136+
this.renderTempText(text, centerLocation.subtract(textSize.divide(2)), size, color);
110137
}
111138

112139
/**
@@ -126,6 +153,29 @@ export class TextRenderer {
126153
lineHeight: number = 1.2,
127154
limitLines: number = Infinity,
128155
): void {
156+
if (text.trim().length === 0) return;
157+
let currentY = 0; // 顶部偏移量
158+
let textLineArray = this.textToTextArrayWrapCache(text, fontSize, limitWidth);
159+
// 限制行数
160+
if (limitLines < textLineArray.length) {
161+
textLineArray = textLineArray.slice(0, limitLines);
162+
textLineArray[limitLines - 1] += "..."; // 最后一行加省略号
163+
}
164+
for (const line of textLineArray) {
165+
this.renderText(line, location.add(new Vector(0, currentY)), fontSize, color);
166+
currentY += fontSize * lineHeight;
167+
}
168+
}
169+
renderTempMultiLineText(
170+
text: string,
171+
location: Vector,
172+
fontSize: number,
173+
limitWidth: number,
174+
color: Color = Color.White,
175+
lineHeight: number = 1.2,
176+
limitLines: number = Infinity,
177+
): void {
178+
if (text.trim().length === 0) return;
129179
text = Settings.sync.protectingPrivacy ? replaceTextWhenProtect(text) : text;
130180
let currentY = 0; // 顶部偏移量
131181
let textLineArray = this.textToTextArrayWrapCache(text, fontSize, limitWidth);
@@ -135,7 +185,7 @@ export class TextRenderer {
135185
textLineArray[limitLines - 1] += "..."; // 最后一行加省略号
136186
}
137187
for (const line of textLineArray) {
138-
this.renderOneLineText(line, location.add(new Vector(0, currentY)), fontSize, color);
188+
this.renderTempText(line, location.add(new Vector(0, currentY)), fontSize, color);
139189
currentY += fontSize * lineHeight;
140190
}
141191
}
@@ -149,6 +199,7 @@ export class TextRenderer {
149199
lineHeight: number = 1.2,
150200
limitLines: number = Infinity,
151201
): void {
202+
if (text.trim().length === 0) return;
152203
text = Settings.sync.protectingPrivacy ? replaceTextWhenProtect(text) : text;
153204
let currentY = 0; // 顶部偏移量
154205
let textLineArray = this.textToTextArrayWrapCache(text, size, limitWidth);
@@ -167,6 +218,34 @@ export class TextRenderer {
167218
currentY += size * lineHeight;
168219
}
169220
}
221+
renderTempMultiLineTextFromCenter(
222+
text: string,
223+
centerLocation: Vector,
224+
size: number,
225+
limitWidth: number,
226+
color: Color,
227+
lineHeight: number = 1.2,
228+
limitLines: number = Infinity,
229+
): void {
230+
if (text.trim().length === 0) return;
231+
text = Settings.sync.protectingPrivacy ? replaceTextWhenProtect(text) : text;
232+
let currentY = 0; // 顶部偏移量
233+
let textLineArray = this.textToTextArrayWrapCache(text, size, limitWidth);
234+
// 限制行数
235+
if (limitLines < textLineArray.length) {
236+
textLineArray = textLineArray.slice(0, limitLines);
237+
textLineArray[limitLines - 1] += "..."; // 最后一行加省略号
238+
}
239+
for (const line of textLineArray) {
240+
this.renderTempTextFromCenter(
241+
line,
242+
centerLocation.add(new Vector(0, currentY - ((textLineArray.length - 1) * size) / 2)),
243+
size,
244+
color,
245+
);
246+
currentY += size * lineHeight;
247+
}
248+
}
170249

171250
textArrayCache: LruCache<string, string[]> = new LruCache(100);
172251

@@ -195,7 +274,7 @@ export class TextRenderer {
195274
private textToTextArray(text: string, fontSize: number, limitWidth: number): string[] {
196275
let currentLine = "";
197276
// 先渲染一下空字符串,否则长度大小可能不匹配,造成蜜汁bug
198-
this.renderOneLineText("", Vector.getZero(), fontSize, Color.White);
277+
this.renderText("", Vector.getZero(), fontSize, Color.White);
199278
const lines: string[] = [];
200279

201280
for (const char of text) {

app/src/core/render/canvas2d/entityRenderer/EntityDetailsButtonRenderer.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export class EntityDetailsButtonRenderer {
2626
isMouseHovering = true;
2727
if (!entity.isEditingDetails)
2828
// 鼠标悬浮在这上面
29-
this.project.textRenderer.renderOneLineText(
29+
this.project.textRenderer.renderText(
3030
"点击展开或关闭节点注释详情",
3131
this.project.renderer.transformWorld2View(
3232
entity.detailsButtonRectangle().topCenter.subtract(new Vector(0, 12)),
@@ -35,7 +35,7 @@ export class EntityDetailsButtonRenderer {
3535
this.project.stageStyleManager.currentStyle.DetailsDebugText,
3636
);
3737
}
38-
this.project.textRenderer.renderOneLineText(
38+
this.project.textRenderer.renderText(
3939
entity.isEditingDetails ? "✏️" : "📃",
4040
this.project.renderer.transformWorld2View(entity.detailsButtonRectangle().leftTop),
4141
(isMouseHovering ? getFontSizeByTime() : 20) * this.project.camera.currentScale,

app/src/core/render/canvas2d/entityRenderer/EntityRenderer.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -275,13 +275,13 @@ export class EntityRenderer {
275275
}
276276
// 调试,缩放信息和位置信息
277277
if (Settings.sync.showDebug) {
278-
this.project.textRenderer.renderOneLineText(
278+
this.project.textRenderer.renderText(
279279
"scale: " + imageNode.scaleNumber.toString(),
280280
this.project.renderer.transformWorld2View(imageNode.rectangle.location.subtract(new Vector(0, 6))),
281281
3 * this.project.camera.currentScale,
282282
Color.Gray,
283283
);
284-
this.project.textRenderer.renderOneLineText(
284+
this.project.textRenderer.renderText(
285285
"origin size: " + imageNode.originImageSize.toString(),
286286
this.project.renderer.transformWorld2View(imageNode.rectangle.location.subtract(new Vector(0, 3 + 6))),
287287
3 * this.project.camera.currentScale,

app/src/core/render/canvas2d/entityRenderer/portalNode/portalNodeRenderer.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export class PortalNodeRenderer {
3232
10 * this.project.camera.currentScale,
3333
);
3434
// 绘制标题,和节点文字大小保持一致
35-
this.project.textRenderer.renderOneLineText(
35+
this.project.textRenderer.renderText(
3636
portalNode.title,
3737
this.project.renderer.transformWorld2View(leftTopLocation.add(Vector.same(Renderer.NODE_PADDING))),
3838
Renderer.FONT_SIZE * this.project.camera.currentScale,
@@ -47,7 +47,7 @@ export class PortalNodeRenderer {
4747
5 * this.project.camera.currentScale,
4848
);
4949
// 绘制文件路径文字
50-
this.project.textRenderer.renderOneLineText(
50+
this.project.textRenderer.renderText(
5151
`path: "${portalNode.portalFilePath}"`,
5252
this.project.renderer.transformWorld2View(
5353
leftTopLocation.add(new Vector(0, PortalNode.TITLE_LINE_Y)).add(Vector.same(Renderer.NODE_PADDING)),
@@ -98,7 +98,7 @@ export class PortalNodeRenderer {
9898
Renderer.NODE_ROUNDED_RADIUS * this.project.camera.currentScale,
9999
);
100100
// 绘制悬浮提示文字
101-
this.project.textRenderer.renderOneLineText(
101+
this.project.textRenderer.renderText(
102102
"双击编辑标题",
103103
this.project.renderer.transformWorld2View(bodyRectangle.leftBottom.add(Vector.same(Renderer.NODE_PADDING))),
104104
Renderer.FONT_SIZE_DETAILS * this.project.camera.currentScale,
@@ -115,7 +115,7 @@ export class PortalNodeRenderer {
115115
Renderer.NODE_ROUNDED_RADIUS * this.project.camera.currentScale,
116116
);
117117
// 绘制悬浮提示文字
118-
this.project.textRenderer.renderOneLineText(
118+
this.project.textRenderer.renderText(
119119
"双击编辑相对路径",
120120
this.project.renderer.transformWorld2View(bodyRectangle.leftBottom.add(Vector.same(Renderer.NODE_PADDING))),
121121
Renderer.FONT_SIZE_DETAILS * this.project.camera.currentScale,

0 commit comments

Comments
 (0)