test(examples): upgrade ui-batch-massive with atlas icons & gradient panels

Programmatic textures are now closer to real game UI:
- gradient panel bg (electric-blue gradient + double-tone border)
- 64×64 icon atlas with 4 icons (sword/heart/bolt/gem); buttons cycle
  through atlas regions via Sprite Rect

Demonstrates the realistic batching scenario: many sprites sharing one
atlas texture still batch into a single draw call.
This commit is contained in:
chenmo.gl
2026-04-26 16:55:58 +08:00
parent fb9561e41d
commit 25bef75d00

View File

@@ -3,12 +3,12 @@
* @category UI
* @thumbnail https://mdn.alipayobjects.com/merchant_appfe/afts/img/A*jjZMTrp-vU8AAAAAAAAAAAAADiR2AQ/original
*
* Stress test on dev/2.0: 96 buttons (192 sub-elements) on a single canvas.
* Each button = bg sprite + icon sprite (different sprites, must overlap correctly).
* Stress test: 6000 buttons (12000 sub-elements) on a single canvas.
* Each button = bg sprite (gradient panel) + icon sprite (atlas-sampled).
* Watch the Stats panel — DrawCall reflects current batching strategy.
*/
import { Stats } from "@galacean/engine-toolkit-stats";
import { Camera, Color, Sprite, Texture2D, TextureFormat, WebGLEngine } from "@galacean/engine";
import { Camera, Color, Sprite, Texture2D, TextureFormat, WebGLEngine, Rect } from "@galacean/engine";
import {
CanvasRenderMode,
Image,
@@ -40,8 +40,20 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => {
canvas.referenceResolution.set(1280, 720);
canvas.resolutionAdaptationMode = ResolutionAdaptationMode.BothAdaptation;
const bgSprite = new Sprite(engine, makeRoundedTexture(engine, [50, 90, 180, 255]));
const iconSprite = new Sprite(engine, makeStarTexture(engine, [255, 200, 60, 255]));
// Two textures: a gradient bg panel and an icon atlas (4 icons in 2×2)
// All buttons share these two textures, so all bg-sprites are batchable, and
// all icon-sprites (using different atlas regions) are also batchable —
// demonstrating cluster-by-(material, texture).
const bgSprite = new Sprite(engine, makeGradientPanel(engine));
const iconAtlasTexture = makeIconAtlas(engine);
// 4 sub-sprites of the same atlas, rotating per button
const iconSprites = [
new Sprite(engine, iconAtlasTexture, new Rect(0, 0, 0.5, 0.5)), // sword
new Sprite(engine, iconAtlasTexture, new Rect(0.5, 0, 0.5, 0.5)), // heart
new Sprite(engine, iconAtlasTexture, new Rect(0, 0.5, 0.5, 0.5)), // bolt
new Sprite(engine, iconAtlasTexture, new Rect(0.5, 0.5, 0.5, 0.5)) // gem
];
// 100×60 = 6000 buttons → 12000 sub-elements
const cols = 100;
@@ -69,9 +81,9 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => {
const icon = canvasEntity.createChild(`icon-${r}-${c}`);
icon.transform.setPosition(x, y, 0);
const iconT = icon.transform as UITransform;
iconT.size.set(buttonW * 0.5, buttonH * 0.5);
iconT.size.set(buttonW * 0.55, buttonH * 0.55);
const iconImg = icon.addComponent(Image);
iconImg.sprite = iconSprite;
iconImg.sprite = iconSprites[(r + c) % 4];
iconImg.color = new Color(1, 1, 1, 1);
}
}
@@ -79,52 +91,149 @@ WebGLEngine.create({ canvas: "canvas" }).then((engine) => {
engine.run();
});
// Rounded panel: filled rect with darker border (mimics 9-slice button background)
function makeRoundedTexture(engine: WebGLEngine, rgba: number[]): Texture2D {
const size = 32;
// Vertical gradient panel with double border (atlas-style game button background)
function makeGradientPanel(engine: WebGLEngine): Texture2D {
const size = 64;
const tex = new Texture2D(engine, size, size, TextureFormat.R8G8B8A8, false);
const data = new Uint8Array(size * size * 4);
const border = 3;
// Base palette: deep navy → electric blue gradient
const top = [70, 130, 240];
const bottom = [25, 55, 130];
const outerBorder = [12, 25, 60];
const innerHighlight = [180, 220, 255];
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const i = (y * size + x) * 4;
const isBorder = x < border || x >= size - border || y < border || y >= size - border;
const tint = isBorder ? 0.6 : 1.0;
data[i] = rgba[0] * tint;
data[i + 1] = rgba[1] * tint;
data[i + 2] = rgba[2] * tint;
data[i + 3] = rgba[3];
const t = y / (size - 1);
// Vertical gradient
let r = top[0] * (1 - t) + bottom[0] * t;
let g = top[1] * (1 - t) + bottom[1] * t;
let b = top[2] * (1 - t) + bottom[2] * t;
// Outer 2px border
if (x < 2 || x >= size - 2 || y < 2 || y >= size - 2) {
r = outerBorder[0];
g = outerBorder[1];
b = outerBorder[2];
}
// Inner 1px highlight on top edge
else if (y < 4 && x >= 2 && x < size - 2) {
r = (r + innerHighlight[0]) * 0.5;
g = (g + innerHighlight[1]) * 0.5;
b = (b + innerHighlight[2]) * 0.5;
}
data[i] = r | 0;
data[i + 1] = g | 0;
data[i + 2] = b | 0;
data[i + 3] = 255;
}
}
tex.setPixelBuffer(data);
return tex;
}
// 5-pointed star silhouette on transparent background (mimics atlas icon)
function makeStarTexture(engine: WebGLEngine, rgba: number[]): Texture2D {
const size = 32;
const tex = new Texture2D(engine, size, size, TextureFormat.R8G8B8A8, false);
const data = new Uint8Array(size * size * 4);
const cx = size / 2;
const cy = size / 2;
const outer = size * 0.45;
const inner = outer * 0.4;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const i = (y * size + x) * 4;
const dx = x + 0.5 - cx;
const dy = y + 0.5 - cy;
const angle = Math.atan2(dy, dx);
const dist = Math.hypot(dx, dy);
const t = (Math.cos(5 * angle - Math.PI / 2) + 1) * 0.5;
const radius = inner + (outer - inner) * t;
const inside = dist <= radius;
data[i] = inside ? rgba[0] : 0;
data[i + 1] = inside ? rgba[1] : 0;
data[i + 2] = inside ? rgba[2] : 0;
data[i + 3] = inside ? rgba[3] : 0;
// 64×64 atlas with 4 icons in 2×2 grid (32×32 each), transparent background
function makeIconAtlas(engine: WebGLEngine): Texture2D {
const cellSize = 32;
const atlasSize = cellSize * 2;
const tex = new Texture2D(engine, atlasSize, atlasSize, TextureFormat.R8G8B8A8, false);
const data = new Uint8Array(atlasSize * atlasSize * 4);
const writePixel = (cx: number, cy: number, lx: number, ly: number, r: number, g: number, b: number, a: number) => {
const x = cx * cellSize + lx;
const y = cy * cellSize + ly;
if (x < 0 || x >= atlasSize || y < 0 || y >= atlasSize) return;
const i = (y * atlasSize + x) * 4;
data[i] = r;
data[i + 1] = g;
data[i + 2] = b;
data[i + 3] = a;
};
// Cell (0, 0): sword icon (silver blade + gold hilt)
for (let y = 0; y < cellSize; y++) {
for (let x = 0; x < cellSize; x++) {
const cx = cellSize / 2 - 0.5;
const cy = cellSize / 2;
const dx = x - cx;
const dy = y - cy;
// diagonal blade strip
const along = (dx + dy) * 0.7071;
const across = (dx - dy) * 0.7071;
const inBlade = Math.abs(across) < 2 && along > -10 && along < 8;
const inHilt = Math.abs(across) < 5 && along >= 8 && along < 11;
const inGuard = Math.abs(across) < 7 && Math.abs(along - 7.5) < 1;
if (inBlade) writePixel(0, 0, x, y, 220, 230, 240, 255);
else if (inHilt) writePixel(0, 0, x, y, 200, 150, 50, 255);
else if (inGuard) writePixel(0, 0, x, y, 150, 110, 30, 255);
}
}
// Cell (1, 0): heart icon
for (let y = 0; y < cellSize; y++) {
for (let x = 0; x < cellSize; x++) {
// Heart equation: ((x²+y²-1)³ - x²y³) <= 0, with proper scaling
const nx = (x - cellSize / 2) / (cellSize / 2.5);
const ny = -(y - cellSize / 2.2) / (cellSize / 2.5);
const v = Math.pow(nx * nx + ny * ny - 1, 3) - nx * nx * Math.pow(ny, 3);
if (v <= 0) {
// Soft red gradient
const t = (ny + 0.5) * 0.5;
const r = (235 - t * 30) | 0;
const g = (50 + t * 30) | 0;
const b = (70 + t * 20) | 0;
writePixel(1, 0, x, y, r, g, b, 255);
}
}
}
// Cell (0, 1): lightning bolt (zigzag)
const boltPath: [number, number][] = [
[16, 4],
[10, 16],
[14, 16],
[11, 28],
[22, 14],
[17, 14],
[21, 4]
];
for (let y = 0; y < cellSize; y++) {
for (let x = 0; x < cellSize; x++) {
// Point-in-polygon
let inside = false;
for (let i = 0, j = boltPath.length - 1; i < boltPath.length; j = i++) {
const [xi, yi] = boltPath[i];
const [xj, yj] = boltPath[j];
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
}
if (inside) writePixel(0, 1, x, y, 255, 215, 60, 255);
}
}
// Cell (1, 1): gem (diamond shape with highlight)
for (let y = 0; y < cellSize; y++) {
for (let x = 0; x < cellSize; x++) {
const cx = cellSize / 2;
const cy = cellSize / 2;
const dx = Math.abs(x - cx);
const dy = Math.abs(y - cy);
// Diamond: |dx| + |dy| < r
if (dx + dy < cellSize * 0.4) {
// Cyan with white highlight at top-left
const high = Math.max(0, 1 - ((x - cx + 6) ** 2 + (y - cy + 6) ** 2) / 50);
const baseR = 80;
const baseG = 220;
const baseB = 230;
const r = (baseR + (255 - baseR) * high) | 0;
const g = (baseG + (255 - baseG) * high) | 0;
const b = (baseB + (255 - baseB) * high) | 0;
writePixel(1, 1, x, y, r, g, b, 255);
}
}
}
tex.setPixelBuffer(data);
return tex;
}