Skip to content

Commit 88d7eb0

Browse files
committed
feat(fe): add canvas support with web-gl (#198)
1 parent a16e5a4 commit 88d7eb0

10 files changed

Lines changed: 1237 additions & 10 deletions

File tree

docs/API-Reference.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Vue 作为底层的渲染框架,自然会与小程序的语法存在一定的
2525
| ------------------ |
2626
| block |
2727
| button |
28+
| canvas |
2829
| checkbox |
2930
| checkbox-group |
3031
| cover-image |
@@ -160,6 +161,9 @@ DMPApp.init(context, { apiNamespaces: ["myapp"] })
160161
| 界面 - 滚动 | pageScrollTo |||||
161162
| 界面 - 菜单 | getMenuButtonBoundingClientRect |||||
162163
| 界面 - 动画 | createAnimation |||||
164+
| 界面 - Canvas | createCanvasContext |||||
165+
| | createOffscreenCanvas |||||
166+
| | canvasToTempFilePath |||||
163167
| WXML | createSelectorQuery |||||
164168
| | createIntersectionObserver |||||
165169
| 网络 | request |||||

fe/packages/compiler/src/common/compatibility-reference.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
const supportedBuiltinComponents = [
55
"block",
66
"button",
7+
"canvas",
78
"checkbox",
89
"checkbox-group",
910
"cover-image",
@@ -65,6 +66,9 @@ const supportedWxApis = [
6566
"pageScrollTo",
6667
"getMenuButtonBoundingClientRect",
6768
"createAnimation",
69+
"createCanvasContext",
70+
"createOffscreenCanvas",
71+
"canvasToTempFilePath",
6872
"createSelectorQuery",
6973
"createIntersectionObserver",
7074
"request",

fe/packages/render/__tests__/runtime.spec.js

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
22
import { JSDOM } from 'jsdom'
33
import { createApp, h, nextTick } from 'vue'
44

@@ -122,4 +122,95 @@ describe('runtime template components', () => {
122122

123123
app.unmount()
124124
})
125+
126+
it('returns a serializable canvas node from selector node fields', async () => {
127+
runtime.ensureElementReady = async element => element
128+
runtime.canvasNodes.clear()
129+
130+
const canvas = document.createElement('canvas')
131+
canvas.setAttribute('type', 'webgl')
132+
canvas.getBoundingClientRect = vi.fn(() => ({
133+
left: 0,
134+
top: 0,
135+
right: 300,
136+
bottom: 300,
137+
width: 300,
138+
height: 300,
139+
}))
140+
document.body.append(canvas)
141+
142+
const result = await runtime.parseElement(canvas, {
143+
node: true,
144+
size: true,
145+
})
146+
147+
expect(result.node.__diminaNodeType).toBe('dimina-canvas-node')
148+
expect(result.node.type).toBe('webgl')
149+
expect(result.node.width).toBe(300)
150+
expect(result.node.height).toBe(300)
151+
expect(canvas.width).toBe(300)
152+
expect(canvas.height).toBe(300)
153+
expect(runtime.canvasNodes.has(result.node.nodeId)).toBe(true)
154+
155+
canvas.width = 600
156+
canvas.height = 600
157+
const nextResult = await runtime.parseElement(canvas, {
158+
node: true,
159+
})
160+
expect(nextResult.node.width).toBe(600)
161+
expect(nextResult.node.height).toBe(600)
162+
expect(canvas.width).toBe(600)
163+
expect(canvas.height).toBe(600)
164+
})
165+
166+
it('replays canvas node webgl operations against the real context', () => {
167+
runtime.canvasNodes.clear()
168+
runtime.canvasResources.clear()
169+
170+
const shader = { kind: 'shader' }
171+
const gl = {
172+
VERTEX_SHADER: 0x8B31,
173+
createShader: vi.fn(() => shader),
174+
shaderSource: vi.fn(),
175+
compileShader: vi.fn(),
176+
viewport: vi.fn(),
177+
}
178+
const canvas = document.createElement('canvas')
179+
canvas.getContext = vi.fn(() => gl)
180+
runtime.canvasNodes.set('canvas_1', {
181+
canvas,
182+
contexts: new Map(),
183+
})
184+
185+
runtime.canvasNodeFlush({
186+
bridgeId: 'bridge_1',
187+
params: {
188+
nodeId: 'canvas_1',
189+
operations: [
190+
{ op: 'setCanvasProperty', prop: 'width', value: 600 },
191+
{ op: 'getContext', contextId: 'ctx_1', contextType: 'webgl' },
192+
{ op: 'contextCall', contextId: 'ctx_1', method: 'viewport', args: [0, 0, 300, 150] },
193+
{ op: 'contextCall', contextId: 'ctx_1', method: 'createShader', args: [0x8B31], resultId: 'shader_1' },
194+
{
195+
op: 'contextCall',
196+
contextId: 'ctx_1',
197+
method: 'shaderSource',
198+
args: [{ __canvasResourceId: 'shader_1' }, 'void main() {}'],
199+
},
200+
{
201+
op: 'contextCall',
202+
contextId: 'ctx_1',
203+
method: 'compileShader',
204+
args: [{ __canvasResourceId: 'shader_1' }],
205+
},
206+
],
207+
},
208+
})
209+
210+
expect(canvas.width).toBe(600)
211+
expect(canvas.getContext).toHaveBeenCalledWith('webgl', undefined)
212+
expect(gl.viewport).toHaveBeenCalledWith(0, 0, 300, 150)
213+
expect(gl.shaderSource).toHaveBeenCalledWith(shader, 'void main() {}')
214+
expect(gl.compileShader).toHaveBeenCalledWith(shader)
215+
})
125216
})

fe/packages/render/src/core/runtime.js

Lines changed: 228 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,23 @@ const VUE_RUNTIME_HELPERS = {
5959
_withDirectives: withDirectives,
6060
}
6161

62+
const CANVAS_NODE_TYPE = 'dimina-canvas-node'
63+
const TYPED_ARRAY_CTORS = {
64+
Int8Array,
65+
Uint8Array,
66+
Uint8ClampedArray,
67+
Int16Array,
68+
Uint16Array,
69+
Int32Array,
70+
Uint32Array,
71+
Float32Array,
72+
Float64Array,
73+
}
74+
75+
function isCanvasElement(element) {
76+
return element?.tagName?.toLowerCase() === 'canvas'
77+
}
78+
6279
class Runtime {
6380
constructor() {
6481
this.app = null
@@ -69,6 +86,9 @@ class Runtime {
6986
this.initializedModules = new Set()
7087
this.preInitUpdates = new Map()
7188
this.intersectionObservers = new Map()
89+
this.canvasNodes = new Map()
90+
this.canvasResources = new Map()
91+
this.canvasRafIds = new Map()
7292
// 追踪"mC 已发出但 service 侧 created 尚未完成"的组件 setup
7393
// key: moduleId, value: Promise(created 完成时 resolve)
7494
this._pendingSetups = new Map()
@@ -711,6 +731,209 @@ class Runtime {
711731
return true
712732
}
713733

734+
getCanvasNodeId(canvas) {
735+
if (!canvas.__diminaCanvasNodeId) {
736+
Object.defineProperty(canvas, '__diminaCanvasNodeId', {
737+
value: `canvas_${uuid()}`,
738+
configurable: true,
739+
})
740+
}
741+
return canvas.__diminaCanvasNodeId
742+
}
743+
744+
registerCanvasNode(canvas, type = canvas.getAttribute?.('type') || '2d') {
745+
const nodeId = this.getCanvasNodeId(canvas)
746+
const isNewNode = !this.canvasNodes.has(nodeId)
747+
const rect = canvas.getBoundingClientRect?.()
748+
const width = Math.round(rect?.width || 0)
749+
const height = Math.round(rect?.height || 0)
750+
if (isNewNode && width > 0 && height > 0) {
751+
if (canvas.width !== width) {
752+
canvas.width = width
753+
}
754+
if (canvas.height !== height) {
755+
canvas.height = height
756+
}
757+
}
758+
759+
if (isNewNode) {
760+
this.canvasNodes.set(nodeId, {
761+
canvas,
762+
contexts: new Map(),
763+
})
764+
}
765+
return {
766+
__diminaNodeType: CANVAS_NODE_TYPE,
767+
nodeId,
768+
type,
769+
width: canvas.width || width || 300,
770+
height: canvas.height || height || 150,
771+
}
772+
}
773+
774+
createOffscreenCanvas({ params }) {
775+
const { nodeId, width = 300, height = 150, type = '2d' } = params
776+
const canvas = document.createElement('canvas')
777+
canvas.width = width
778+
canvas.height = height
779+
this.canvasNodes.set(nodeId, {
780+
canvas,
781+
type,
782+
contexts: new Map(),
783+
})
784+
}
785+
786+
resolveCanvasArg(value) {
787+
if (value === null || value === undefined) {
788+
return value
789+
}
790+
791+
if (Array.isArray(value)) {
792+
return value.map(item => this.resolveCanvasArg(item))
793+
}
794+
795+
if (typeof value !== 'object') {
796+
return value
797+
}
798+
799+
if (value.__canvasResourceId) {
800+
return this.canvasResources.get(value.__canvasResourceId)
801+
}
802+
803+
if (value.__canvasNodeId) {
804+
return this.canvasNodes.get(value.__canvasNodeId)?.canvas
805+
}
806+
807+
if (value.__canvasTypedArray) {
808+
const Ctor = TYPED_ARRAY_CTORS[value.__canvasTypedArray]
809+
if (Ctor) {
810+
return new Ctor(value.data || [])
811+
}
812+
if (value.__canvasTypedArray === 'DataView') {
813+
return new DataView(new Uint8Array(value.data || []).buffer)
814+
}
815+
}
816+
817+
if (value.__canvasArrayBuffer) {
818+
return new Uint8Array(value.data || []).buffer
819+
}
820+
821+
const result = {}
822+
for (const [key, item] of Object.entries(value)) {
823+
result[key] = this.resolveCanvasArg(item)
824+
}
825+
return result
826+
}
827+
828+
getCanvasResource(id) {
829+
return this.canvasResources.get(id)
830+
}
831+
832+
setCanvasResource(id, value) {
833+
if (id) {
834+
this.canvasResources.set(id, value)
835+
}
836+
}
837+
838+
getCanvasImage(imageId) {
839+
let image = this.getCanvasResource(imageId)
840+
if (!image) {
841+
image = new Image()
842+
this.setCanvasResource(imageId, image)
843+
}
844+
return image
845+
}
846+
847+
executeCanvasOperation(node, operation, bridgeId) {
848+
switch (operation.op) {
849+
case 'setCanvasProperty':
850+
node.canvas[operation.prop] = operation.value
851+
break
852+
case 'getContext': {
853+
const context = node.canvas.getContext(operation.contextType, this.resolveCanvasArg(operation.attributes))
854+
node.contexts.set(operation.contextId, context)
855+
this.setCanvasResource(operation.contextId, context)
856+
break
857+
}
858+
case 'contextSetProperty': {
859+
const context = this.getCanvasResource(operation.contextId)
860+
if (context) {
861+
context[operation.prop] = this.resolveCanvasArg(operation.value)
862+
}
863+
break
864+
}
865+
case 'contextCall': {
866+
const context = this.getCanvasResource(operation.contextId)
867+
const method = context?.[operation.method]
868+
if (typeof method === 'function') {
869+
const result = method.apply(context, (operation.args || []).map(arg => this.resolveCanvasArg(arg)))
870+
this.setCanvasResource(operation.resultId, result)
871+
}
872+
break
873+
}
874+
case 'resourceCall': {
875+
const resource = this.getCanvasResource(operation.resourceId)
876+
const method = resource?.[operation.method]
877+
if (typeof method === 'function') {
878+
const result = method.apply(resource, (operation.args || []).map(arg => this.resolveCanvasArg(arg)))
879+
this.setCanvasResource(operation.resultId, result)
880+
}
881+
break
882+
}
883+
case 'createImage':
884+
this.getCanvasImage(operation.imageId)
885+
break
886+
case 'imageSetSrc': {
887+
const image = this.getCanvasImage(operation.imageId)
888+
image.onload = () => {
889+
this.triggerCallback(bridgeId, operation.onload, {
890+
width: image.width,
891+
height: image.height,
892+
})
893+
}
894+
image.onerror = () => {
895+
this.triggerCallback(bridgeId, operation.onerror, {
896+
errMsg: `createImage:fail ${operation.src}`,
897+
})
898+
}
899+
image.src = operation.src
900+
break
901+
}
902+
default:
903+
console.warn('[system]', '[render]', `Unsupported canvas node operation: ${operation.op}`)
904+
}
905+
}
906+
907+
canvasNodeFlush({ bridgeId, params }) {
908+
const node = this.canvasNodes.get(params.nodeId)
909+
if (!node) {
910+
console.warn('[system]', '[render]', `canvas node ${params.nodeId} not found`)
911+
return
912+
}
913+
914+
for (const operation of params.operations || []) {
915+
this.executeCanvasOperation(node, operation, bridgeId)
916+
}
917+
}
918+
919+
canvasNodeRequestAnimationFrame({ bridgeId, params }) {
920+
const key = `${params.nodeId}:${params.requestId}`
921+
const frameId = requestAnimationFrame((timestamp) => {
922+
this.canvasRafIds.delete(key)
923+
this.triggerCallback(bridgeId, params.callback, timestamp)
924+
})
925+
this.canvasRafIds.set(key, frameId)
926+
}
927+
928+
canvasNodeCancelAnimationFrame({ params }) {
929+
const key = `${params.nodeId}:${params.requestId}`
930+
const frameId = this.canvasRafIds.get(key)
931+
if (frameId !== undefined) {
932+
cancelAnimationFrame(frameId)
933+
this.canvasRafIds.delete(key)
934+
}
935+
}
936+
714937
async selectorQuery(opts) {
715938
const { bridgeId, params: { tasks, success } } = opts
716939

@@ -886,9 +1109,11 @@ class Runtime {
8861109
data.computedStyle = styles
8871110
}
8881111

889-
// TODO: 支持获取 Canvas 和 ScrollViewContext
890-
// if (fields.node) {
891-
// }
1112+
if (fields.node) {
1113+
data.node = isCanvasElement(targetElement)
1114+
? this.registerCanvasNode(targetElement)
1115+
: null
1116+
}
8921117
// TODO: 支持获取 VideoContext、CanvasContext、LivePlayerContext、EditorContext和 MapContext
8931118
// if (fields.context) {
8941119
// }

0 commit comments

Comments
 (0)