diff --git a/common/changes/@visactor/vrender-core/feat-vrender-support-custom-texture_2026-04-07-07-44.json b/common/changes/@visactor/vrender-core/feat-vrender-support-custom-texture_2026-04-07-07-44.json
new file mode 100644
index 000000000..f7c36b03c
--- /dev/null
+++ b/common/changes/@visactor/vrender-core/feat-vrender-support-custom-texture_2026-04-07-07-44.json
@@ -0,0 +1,10 @@
+{
+ "changes": [
+ {
+ "packageName": "@visactor/vrender-core",
+ "comment": "feat: graphic support custom texture",
+ "type": "none"
+ }
+ ],
+ "packageName": "@visactor/vrender-core"
+}
\ No newline at end of file
diff --git a/common/changes/@visactor/vrender/feat-vrender-support-custom-texture_2026-04-07-07-44.json b/common/changes/@visactor/vrender/feat-vrender-support-custom-texture_2026-04-07-07-44.json
new file mode 100644
index 000000000..0536218c3
--- /dev/null
+++ b/common/changes/@visactor/vrender/feat-vrender-support-custom-texture_2026-04-07-07-44.json
@@ -0,0 +1,10 @@
+{
+ "changes": [
+ {
+ "packageName": "@visactor/vrender",
+ "comment": "feat: graphic support custom texture",
+ "type": "none"
+ }
+ ],
+ "packageName": "@visactor/vrender"
+}
\ No newline at end of file
diff --git a/packages/vrender-core/__tests__/graphic/texture-custom.test.ts b/packages/vrender-core/__tests__/graphic/texture-custom.test.ts
new file mode 100644
index 000000000..60f5e903d
--- /dev/null
+++ b/packages/vrender-core/__tests__/graphic/texture-custom.test.ts
@@ -0,0 +1,89 @@
+/* eslint-disable @typescript-eslint/ban-ts-comment */
+// @ts-nocheck
+import { Rect } from '../../src/graphic/rect';
+import { application } from '../../src/application';
+
+describe('texture-custom (resource)', () => {
+ beforeAll(() => {
+ application.global = {
+ loadSvg: jest.fn(() => Promise.resolve({ data: null })),
+ loadImage: jest.fn(() => Promise.resolve({ data: null })),
+ getRequestAnimationFrame: () => (cb: () => void) => cb()
+ } as any;
+ application.graphicService = {
+ onAttributeUpdate: jest.fn(),
+ updateTempAABBBounds: jest.fn(() => ({
+ x1: 0,
+ y1: 0,
+ x2: 0,
+ y2: 0,
+ clear: jest.fn(),
+ union: jest.fn(),
+ setValue: jest.fn()
+ })),
+ transformAABBBounds: jest.fn()
+ } as any;
+ });
+
+ it('creates resource cache when texture is HTMLCanvasElement in constructor', () => {
+ const canvas = document.createElement('canvas');
+ const rect = new Rect({
+ x: 0,
+ y: 0,
+ width: 10,
+ height: 10,
+ texture: canvas
+ });
+
+ const res = rect.resources?.get(canvas);
+ expect(res?.state).toBe('success');
+ expect(res?.data).toBe(canvas);
+ });
+
+ it('creates resource cache when setAttribute texture is HTMLCanvasElement', () => {
+ const canvas = document.createElement('canvas');
+ const rect = new Rect({
+ x: 0,
+ y: 0,
+ width: 10,
+ height: 10
+ });
+
+ rect.setAttribute('texture', canvas);
+
+ const res = rect.resources?.get(canvas);
+ expect(res?.state).toBe('success');
+ expect(res?.data).toBe(canvas);
+ });
+
+ it('marks resource as loading when texture is svg string', () => {
+ const svg = ``;
+ const rect = new Rect({
+ x: 0,
+ y: 0,
+ width: 10,
+ height: 10,
+ texture: svg
+ });
+
+ const res = rect.resources?.get(svg);
+ expect(res?.state).toBe('loading');
+ expect(res?.data).toBe('init');
+ });
+
+ it('marks resource as loading when texture is image url', () => {
+ const url =
+ 'https://lf-dp.bytetos.com/obj/dp-open-internet-cn/visactor-site/bytedance/client/img/visactor/navigator-logo.svg';
+ const rect = new Rect({
+ x: 0,
+ y: 0,
+ width: 10,
+ height: 10,
+ texture: url
+ });
+
+ const res = rect.resources?.get(url);
+ expect(res?.state).toBe('loading');
+ expect(res?.data).toBe('init');
+ });
+});
diff --git a/packages/vrender-core/src/graphic/graphic.ts b/packages/vrender-core/src/graphic/graphic.ts
index bf48f41ea..2e51aaffa 100644
--- a/packages/vrender-core/src/graphic/graphic.ts
+++ b/packages/vrender-core/src/graphic/graphic.ts
@@ -118,6 +118,17 @@ export const GRAPHIC_UPDATE_TAG_KEY = [
const tempConstantXYKey = ['x', 'y'];
const tempConstantScaleXYKey = ['scaleX', 'scaleY'];
const tempConstantAngleKey = ['angle'];
+const builtinTextureTypes = new Set([
+ 'circle',
+ 'diamond',
+ 'rect',
+ 'vertical-line',
+ 'horizontal-line',
+ 'bias-lr',
+ 'bias-rl',
+ 'grid',
+ 'wave'
+]);
const point = new Point();
@@ -259,29 +270,29 @@ export abstract class Graphic = Partial = Partial = Partial = Partial = Partial {
time: BaseRenderContributionTime = BaseRenderContributionTime.afterFillStroke;
useStyle: boolean = true;
- textureMap?: Map;
+ textureMap?: Map;
order: number = 10;
_tempSymbolGraphic: ISymbol | null = null;
@@ -236,7 +236,7 @@ export class DefaultBaseTextureRenderContribution implements IBaseRenderContribu
}
protected drawTexture(
- texture: string,
+ texture: string | HTMLImageElement | HTMLCanvasElement,
graphic: IGraphic,
context: IContext2d,
x: number,
@@ -247,38 +247,50 @@ export class DefaultBaseTextureRenderContribution implements IBaseRenderContribu
texturePadding: number
) {
const { textureRatio = graphicAttribute.textureRatio, textureOptions = null } = graphic.attribute;
- let pattern: CanvasPattern = this.textureMap.get(texture);
+ let pattern: CanvasPattern = null;
+ const patternKey = this.getPatternCacheKey(texture, textureSize, texturePadding, textureColor, context.dpr);
+ if (patternKey !== null) {
+ pattern = this.textureMap.get(patternKey);
+ }
if (!pattern) {
- switch (texture) {
- case 'circle':
- pattern = this.createCirclePattern(textureSize, texturePadding, textureColor, context);
- break;
- case 'diamond':
- pattern = this.createDiamondPattern(textureSize, texturePadding, textureColor, context);
- break;
- case 'rect':
- pattern = this.createRectPattern(textureSize, texturePadding, textureColor, context);
- break;
- case 'vertical-line':
- pattern = this.createVerticalLinePattern(textureSize, texturePadding, textureColor, context);
- break;
- case 'horizontal-line':
- pattern = this.createHorizontalLinePattern(textureSize, texturePadding, textureColor, context);
- break;
- case 'bias-lr':
- pattern = this.createBiasLRLinePattern(textureSize, texturePadding, textureColor, context);
- break;
- case 'bias-rl':
- pattern = this.createBiasRLLinePattern(textureSize, texturePadding, textureColor, context);
- break;
- case 'grid':
- pattern = this.createGridPattern(textureSize, texturePadding, textureColor, context);
- break;
+ if (typeof texture === 'string') {
+ switch (texture) {
+ case 'circle':
+ pattern = this.createCirclePattern(textureSize, texturePadding, textureColor, context);
+ break;
+ case 'diamond':
+ pattern = this.createDiamondPattern(textureSize, texturePadding, textureColor, context);
+ break;
+ case 'rect':
+ pattern = this.createRectPattern(textureSize, texturePadding, textureColor, context);
+ break;
+ case 'vertical-line':
+ pattern = this.createVerticalLinePattern(textureSize, texturePadding, textureColor, context);
+ break;
+ case 'horizontal-line':
+ pattern = this.createHorizontalLinePattern(textureSize, texturePadding, textureColor, context);
+ break;
+ case 'bias-lr':
+ pattern = this.createBiasLRLinePattern(textureSize, texturePadding, textureColor, context);
+ break;
+ case 'bias-rl':
+ pattern = this.createBiasRLLinePattern(textureSize, texturePadding, textureColor, context);
+ break;
+ case 'grid':
+ pattern = this.createGridPattern(textureSize, texturePadding, textureColor, context);
+ break;
+ }
+ }
+ if (!pattern) {
+ pattern = this.createResourcePattern(texture, graphic, context);
+ }
+ if (pattern && patternKey !== null) {
+ this.textureMap.set(patternKey, pattern);
}
}
- if (textureOptions && textureOptions.dynamicTexture) {
+ if (typeof texture === 'string' && textureOptions && textureOptions.dynamicTexture) {
// 动态纹理
const { gridConfig = {}, useNewCanvas } = textureOptions;
const b = graphic.AABBBounds;
@@ -396,6 +408,37 @@ export class DefaultBaseTextureRenderContribution implements IBaseRenderContribu
context.restore();
}
}
+
+ protected getPatternCacheKey(
+ texture: string | HTMLImageElement | HTMLCanvasElement,
+ textureSize: number,
+ texturePadding: number,
+ textureColor: string,
+ dpr: number
+ ) {
+ if (typeof texture !== 'string') {
+ return texture;
+ }
+ if (texture === 'wave') {
+ return null;
+ }
+ return `${texture}-${textureSize}-${texturePadding}-${textureColor}-${dpr}`;
+ }
+
+ protected createResourcePattern(
+ texture: string | HTMLImageElement | HTMLCanvasElement,
+ graphic: IGraphic,
+ context: IContext2d
+ ) {
+ const resource = graphic.resources?.get(texture as any);
+ const data = resource?.state === 'success' ? resource.data : typeof texture === 'object' ? texture : null;
+ if (!data) {
+ return null;
+ }
+ const pattern = context.createPattern(data, 'repeat');
+ pattern?.setTransform && pattern.setTransform(new DOMMatrix([1 / context.dpr, 0, 0, 1 / context.dpr, 0, 0]));
+ return pattern;
+ }
}
export const defaultBaseTextureRenderContribution = new DefaultBaseTextureRenderContribution();
diff --git a/packages/vrender/__tests__/browser/src/pages/texture.ts b/packages/vrender/__tests__/browser/src/pages/texture.ts
index 89dfc48d5..952b5884b 100644
--- a/packages/vrender/__tests__/browser/src/pages/texture.ts
+++ b/packages/vrender/__tests__/browser/src/pages/texture.ts
@@ -1,4 +1,4 @@
-import { createStage, createSymbol, container, createArc } from '@visactor/vrender';
+import { createStage, createSymbol, container, createArc, createRect } from '@visactor/vrender';
import { roughModule } from '@visactor/vrender-kits';
import { addShapesToStage, colorPools } from '../utils';
@@ -6,7 +6,8 @@ import { addShapesToStage, colorPools } from '../utils';
export const page = () => {
const shapes = [];
-
+ const svgTexture = ``;
+ const imgTexture = `https://lf-dp.bytetos.com/obj/dp-open-internet-cn/visactor-site/bytedance/client/img/visactor/navigator-logo.svg`;
shapes.push(
createSymbol({
x: 300,
@@ -37,6 +38,37 @@ export const page = () => {
})
);
+ shapes.push(
+ createArc({
+ x: 720,
+ y: 180,
+ innerRadius: 20,
+ outerRadius: 80,
+ startAngle: 0,
+ endAngle: Math.PI * 1.5,
+ fill: '#f3f5f7',
+ stroke: '#333',
+ lineWidth: 2,
+ texture: svgTexture
+ })
+ );
+
+ // image texture 示例(图片平铺)
+ shapes.push(
+ createRect({
+ x: 880,
+ y: 320,
+ width: 260,
+ height: 200,
+ fill: '#f3f5f7',
+ stroke: '#333',
+ lineWidth: 2,
+ texture: imgTexture,
+ textureSize: 80,
+ texturePadding: 6
+ })
+ );
+
// shapes.push(
// createArc({
// x: 700,