@flighthq/render-wgpu 0.2.1-next.666.4af7f8b → 0.2.1-next.738.07b221d

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import { createBitmap } from '@flighthq/displayobject';
2
2
  import { getOrCreateRenderProxy2D, prepareDisplayObjectRender } from '@flighthq/render';
3
- import type { ImageResource } from '@flighthq/types';
3
+ import type { ImageResource, VideoTexture } from '@flighthq/types';
4
4
  import { BlendMode } from '@flighthq/types';
5
5
 
6
6
  import { renderWgpuBackground, submitWgpuRenderPass } from './wgpuBackground';
@@ -8,10 +8,12 @@ import {
8
8
  applyWgpuBlendMode,
9
9
  bindWgpuImageResourceTexture,
10
10
  bindWgpuTexture,
11
+ bindWgpuVideoTexture,
11
12
  buildWgpuRenderTargetBindGroup,
12
13
  createWgpuTextureEntry,
13
14
  drawWgpuQuad,
14
15
  drawWgpuQuadWithTransform,
16
+ destroyWgpuVideoTexture,
15
17
  enableWgpuBlendModeSupport,
16
18
  getWgpuRenderProxyColorTransform,
17
19
  submitWgpuQuadDraw,
@@ -125,6 +127,61 @@ describe('bindWgpuTexture', () => {
125
127
  });
126
128
  });
127
129
 
130
+ describe('bindWgpuVideoTexture', () => {
131
+ function videoTexture(frameId: number, readyState = 4, width = 320, height = 240): VideoTexture {
132
+ return {
133
+ frameId,
134
+ sampler: {
135
+ anisotropy: 1,
136
+ magFilter: 'linear',
137
+ minFilter: 'linear',
138
+ mipmaps: false,
139
+ wrapU: 'clamp-to-edge',
140
+ wrapV: 'clamp-to-edge',
141
+ },
142
+ source: { element: { readyState, videoHeight: height, videoWidth: width } as HTMLVideoElement },
143
+ } as unknown as VideoTexture;
144
+ }
145
+
146
+ it('uploads once per frameId and caches the texture by VideoTexture identity', async () => {
147
+ const state = await createWgpuRenderStateForTest();
148
+ const copy = vi.spyOn(state.device.queue, 'copyExternalImageToTexture');
149
+ const video = videoTexture(1);
150
+ const first = bindWgpuVideoTexture(state, video);
151
+ expect(first).not.toBeNull();
152
+ expect(copy).toHaveBeenCalledTimes(1);
153
+ expect(bindWgpuVideoTexture(state, video)).toBe(first);
154
+ expect(copy).toHaveBeenCalledTimes(1);
155
+ video.frameId = 2;
156
+ expect(bindWgpuVideoTexture(state, video)).toBe(first);
157
+ expect(copy).toHaveBeenCalledTimes(2);
158
+ });
159
+
160
+ it('waits for a decoded frame and recreates the texture after a resolution change', async () => {
161
+ const state = await createWgpuRenderStateForTest();
162
+ const video = videoTexture(1, 1, 0, 0);
163
+ expect(bindWgpuVideoTexture(state, video)).toBeNull();
164
+ const element = video.source.element!;
165
+ Object.assign(element, { readyState: 4, videoHeight: 120, videoWidth: 160 });
166
+ const first = bindWgpuVideoTexture(state, video)!;
167
+ const destroy = vi.spyOn(first.texture, 'destroy');
168
+ Object.assign(element, { videoHeight: 240, videoWidth: 320 });
169
+ const second = bindWgpuVideoTexture(state, video)!;
170
+ expect(second).not.toBe(first);
171
+ expect(destroy).toHaveBeenCalled();
172
+ });
173
+
174
+ it('destroys and removes an uploaded video texture', async () => {
175
+ const state = await createWgpuRenderStateForTest();
176
+ const video = videoTexture(1);
177
+ const entry = bindWgpuVideoTexture(state, video)!;
178
+ const destroy = vi.spyOn(entry.texture, 'destroy');
179
+ expect(destroyWgpuVideoTexture(state, video)).toBe(true);
180
+ expect(destroy).toHaveBeenCalled();
181
+ expect(destroyWgpuVideoTexture(state, video)).toBe(false);
182
+ });
183
+ });
184
+
128
185
  describe('buildWgpuRenderTargetBindGroup', () => {
129
186
  it('returns a bind group for a view', async () => {
130
187
  const state = await createWgpuRenderStateForTest();
@@ -146,6 +203,12 @@ describe('createWgpuTextureEntry', () => {
146
203
  });
147
204
  });
148
205
 
206
+ describe('destroyWgpuVideoTexture', () => {
207
+ it('is the public disposal entry point for dynamic video uploads', () => {
208
+ expect(typeof destroyWgpuVideoTexture).toBe('function');
209
+ });
210
+ });
211
+
149
212
  describe('drawWgpuQuad', () => {
150
213
  it('does not throw when render pass is open', async () => {
151
214
  const state = await createWgpuRenderStateForTest();
@@ -83,12 +83,14 @@ describe('getWgpuRenderStateRuntime', () => {
83
83
  });
84
84
 
85
85
  describe('getWgpuSampler', () => {
86
- it('caches a sampler per filter+wrap+mip+anisotropy key and reuses it', async () => {
86
+ it('caches a sampler per filter+wrap+mip+anisotropy config and reuses it (one createSampler)', async () => {
87
87
  const state = await createWgpuRenderStateForTest();
88
88
  const a = getWgpuSampler(state, 'linear', 'repeat', 'repeat');
89
89
  const b = getWgpuSampler(state, 'linear', 'repeat', 'repeat');
90
90
  expect(a).toBe(b);
91
- expect(getWgpuRenderStateRuntime(state).samplerCache.get('linear|repeat|repeat|none|1')).toBe(a);
91
+ // The cache is keyed by a packed NUMBER (no per-call string allocation), and one config caches once.
92
+ expect([...getWgpuRenderStateRuntime(state).samplerCache.keys()].every((k) => typeof k === 'number')).toBe(true);
93
+ expect(getWgpuRenderStateRuntime(state).samplerCache.size).toBe(1);
92
94
  });
93
95
 
94
96
  it('returns a distinct sampler for a different wrap or filter', async () => {
@@ -105,17 +107,16 @@ describe('getWgpuSampler', () => {
105
107
  const noMip = getWgpuSampler(state, 'linear', 'repeat', 'repeat');
106
108
  const trilinear = getWgpuSampler(state, 'linear', 'repeat', 'repeat', 'linear');
107
109
  expect(noMip).not.toBe(trilinear);
108
- expect(getWgpuRenderStateRuntime(state).samplerCache.has('linear|repeat|repeat|linear|1')).toBe(true);
110
+ expect(getWgpuRenderStateRuntime(state).samplerCache.size).toBe(2);
109
111
  });
110
112
 
111
113
  it('forces linear filtering and a linear mip filter when anisotropy exceeds 1', async () => {
112
114
  // WebGPU rejects maxAnisotropy > 1 unless min/mag/mip are all linear, so a nearest+aniso request
113
- // collapses to the linear anisotropic key.
115
+ // collapses to the SAME sampler as the explicit linear/trilinear anisotropic request.
114
116
  const state = await createWgpuRenderStateForTest();
115
- const sampler = getWgpuSampler(state, 'nearest', 'clamp-to-edge', 'clamp-to-edge', undefined, 8);
116
- const cache = getWgpuRenderStateRuntime(state).samplerCache;
117
- expect(cache.get('linear|clamp-to-edge|clamp-to-edge|linear|8')).toBe(sampler);
118
- expect(cache.has('nearest|clamp-to-edge|clamp-to-edge|none|8')).toBe(false);
117
+ const collapsed = getWgpuSampler(state, 'nearest', 'clamp-to-edge', 'clamp-to-edge', undefined, 8);
118
+ const explicit = getWgpuSampler(state, 'linear', 'clamp-to-edge', 'clamp-to-edge', 'linear', 8);
119
+ expect(collapsed).toBe(explicit);
119
120
  });
120
121
 
121
122
  it('floors and clamps the anisotropy level into the cache key', async () => {
@@ -123,7 +124,7 @@ describe('getWgpuSampler', () => {
123
124
  const a = getWgpuSampler(state, 'linear', 'repeat', 'repeat', 'linear', 4.9);
124
125
  const b = getWgpuSampler(state, 'linear', 'repeat', 'repeat', 'linear', 4);
125
126
  expect(a).toBe(b);
126
- expect(getWgpuRenderStateRuntime(state).samplerCache.has('linear|repeat|repeat|linear|4')).toBe(true);
127
+ expect(getWgpuRenderStateRuntime(state).samplerCache.size).toBe(1);
127
128
  });
128
129
  });
129
130
 
@@ -6,6 +6,7 @@ import {
6
6
  createWgpuBindGroupLayouts,
7
7
  createWgpuPipelineLayout,
8
8
  getActiveWgpuPipeline,
9
+ getWgpuBlendState,
9
10
  getWgpuPipeline,
10
11
  setWgpuMatrixFromTransform,
11
12
  UNIFORM_BYTE_SIZE,
@@ -51,6 +52,29 @@ describe('getActiveWgpuPipeline', () => {
51
52
  });
52
53
  });
53
54
 
55
+ describe('getWgpuBlendState', () => {
56
+ it('resolves all six fixed states and falls back to Normal for null or unknown modes', () => {
57
+ const color = (mode: BlendMode | null) => getWgpuBlendState(mode).color;
58
+ const normal = { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' };
59
+ expect(color(null)).toEqual(normal);
60
+ expect(color(AdvancedBlendMode.Overlay)).toEqual(normal);
61
+ expect(color(BlendMode.Normal)).toEqual(normal);
62
+ expect(color(BlendMode.Add)).toEqual({ srcFactor: 'one', dstFactor: 'one', operation: 'add' });
63
+ expect(color(BlendMode.Multiply)).toEqual({
64
+ srcFactor: 'dst',
65
+ dstFactor: 'one-minus-src-alpha',
66
+ operation: 'add',
67
+ });
68
+ expect(color(BlendMode.Screen)).toEqual({
69
+ srcFactor: 'one',
70
+ dstFactor: 'one-minus-src',
71
+ operation: 'add',
72
+ });
73
+ expect(color(BlendMode.Darken).operation).toBe('min');
74
+ expect(color(BlendMode.Lighten).operation).toBe('max');
75
+ });
76
+ });
77
+
54
78
  describe('getWgpuPipeline', () => {
55
79
  it('returns a cached pipeline on second call', async () => {
56
80
  const state = await createWgpuRenderStateForTest();
@@ -177,4 +201,22 @@ describe('writeWgpuQuadUniforms', () => {
177
201
  expect(runtime.uniformData[floatBase + 28]).toBeCloseTo(0.1);
178
202
  expect(runtime.uniformData[floatBase + 12]).toBeCloseTo(0.5); // alpha
179
203
  });
204
+
205
+ it('writes the native straight-alpha texture flag at u32 offset 14', async () => {
206
+ const state = await createWgpuRenderStateForTest();
207
+ const runtime = getWgpuRenderStateRuntime(state);
208
+ renderWgpuBackground(state);
209
+ const fakeNode = {
210
+ alpha: 1,
211
+ useColorTransform: false,
212
+ colorTransform: null,
213
+ transform2D: { a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0 },
214
+ };
215
+
216
+ const straightOffset = writeWgpuQuadUniforms(state, fakeNode, null, 0, 0, 1, 1, 0, 0, 1, 1, true);
217
+ const premultipliedOffset = writeWgpuQuadUniforms(state, fakeNode, null, 0, 0, 1, 1, 0, 0, 1, 1);
218
+
219
+ expect(runtime.uniformDataU32[(straightOffset >> 2) + 14]).toBe(1);
220
+ expect(runtime.uniformDataU32[(premultipliedOffset >> 2) + 14]).toBe(0);
221
+ });
180
222
  });