diff --git a/cli/version.ts b/cli/version.ts index 24ca894..2bffd64 100644 --- a/cli/version.ts +++ b/cli/version.ts @@ -1 +1 @@ -export const VERSION = "2.5.4"; \ No newline at end of file +export const VERSION = "2.5.5"; \ No newline at end of file diff --git a/deps.ts b/deps.ts index c445f12..33ade7c 100644 --- a/deps.ts +++ b/deps.ts @@ -1,2 +1,2 @@ -export * from "https://deno.land/x/daybreak@0.1.0/mod.ts"; +export * from "https://deno.land/x/daybreak@0.1.2/mod.ts"; export { Matrix4 } from "https://deno.land/x/gmath@0.1.11/mod.ts"; diff --git a/src/renderers/webgpu/GPURenderer.ts b/src/renderers/webgpu/GPURenderer.ts new file mode 100644 index 0000000..7feebc6 --- /dev/null +++ b/src/renderers/webgpu/GPURenderer.ts @@ -0,0 +1,240 @@ +// deno-lint-ignore-file no-explicit-any +import { Entity } from "../../entities/Entity.ts"; +import { + AtlasSprite, + Group, + Image, + Rectangle, + TextureSprite, +} from "../../../mod.ts"; +import { RGBA } from "../../types.ts"; +import { World } from "../../World.ts"; +import { + createBindGroupLayout, + createRenderPipeline, + mipMapShader, + shader2d, + Textures2D, + Uniforms2D, +} from "./shader2d.ts"; +import { + EntityBuffers, + ImageBuffers, + Layouts, + RectangleBuffers, +} from "./types.ts"; +import { EventManager } from "../../events/EventManager.ts"; +import { hexToRGBA } from "../../utils/HexToRGBA.ts"; +import { createBuffer, loadTexture } from "./util.ts"; + +export class GPURenderer { + #device?: GPUDevice; + #pipeline?: GPURenderPipeline; + // @ts-ignore: typescript is weird + #canvas: HTMLCanvasElement; + // @ts-ignore: typescript is weird + #context?: RenderingContext; + #layouts?: Layouts; + #sampler?: GPUSampler; + #emptyBuffer?: GPUBuffer; + #emptyTexture?: Textures2D; + #mipMapShaderModule?: GPUShaderModule; + + #buffers: Map = new Map(); + #backgroundColor: RGBA = [0.0, 0.0, 0.0, 0.0]; + + eventManager: EventManager = new EventManager(); + + constructor(public world: World) { + this.#canvas = (this.world as any).canvas; + } + async init() { + const adapter = await (navigator as any).gpu.requestAdapter() as GPUAdapter; + const device = await adapter.requestDevice(); + this.#context = this.#canvas.getContext("webgpu") /*as GPUCanvasContext*/; + const devicePixelRatio = (window as any).devicePixelRatio || 1; + const _presentationSize = [ + this.#canvas.clientWidth * devicePixelRatio, + this.#canvas.clientHeight * devicePixelRatio, + ]; + + if (!device) throw new Error(`Could not request device!`); + this.#device = device; + // @ts-ignore new feature + const format = navigator.gpu.getPreferredCanvasFormat(adapter); + this.#context.configure({ + device: this.#device, + format: format, + alphaMode: "opaque", + }); + + this.#layouts = createBindGroupLayout(device); + const layout = this.#device.createPipelineLayout({ + bindGroupLayouts: [this.#layouts.texture, this.#layouts.uniform], + }); + const module = this.#device.createShaderModule({ code: shader2d }); + this.#pipeline = createRenderPipeline(this.#device, module, layout, format); + this.#sampler = device.createSampler({}); + this.#mipMapShaderModule = device.createShaderModule({code: mipMapShader}); + this.#emptyBuffer = device.createBuffer({ + size: 32, + usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, + }); + this.#emptyTexture = Textures2D.empty( + device, + this.#layouts.texture, + this.#sampler, + ); + } + + start(entities: Entity[]) { + for (const entity of entities) { + if (entity instanceof Rectangle) { + this.#setupRectangle(entity); + } else if (entity instanceof Image || entity instanceof AtlasSprite) { + this.#setupImage(entity); + } else if (entity instanceof TextureSprite) { + for (const rect of entity.data) { + this.#setupRectangle(rect); + } + } else if (entity instanceof Group) { + this.start(entity.children); + } + } + } + + render(entities: Entity[]) { + const encoder = this.#device!.createCommandEncoder(); + const textureView = (this.#context as any).getCurrentTexture().createView(); + const renderPass = encoder.beginRenderPass({ + colorAttachments: [ + { + view: textureView, + storeOp: "store", + // @ts-ignore clear not recognized or something + loadOp: "clear", + clearValue: this.#backgroundColor, + }, + ], + }); + renderPass.setPipeline(this.#pipeline!); + this.#render(entities, renderPass); + // @ts-ignore end is being weird + renderPass.end(); + this.#device!.queue.submit([encoder.finish()]); + } + #render(entities: Entity[], renderPass: GPURenderPassEncoder): void { + try { + for (const entity of entities) { + if (entity instanceof Rectangle) { + this.#renderRectangle(entity, renderPass); + } else if (entity instanceof Image || entity instanceof AtlasSprite) { + this.#renderImage(entity, renderPass); + } else if (entity instanceof TextureSprite) { + for (const rect of entity.data) { + this.#renderRectangle(rect, renderPass); + } + } else if (entity instanceof Group) { + this.#render(entity.children, renderPass); + } + } + } catch (_e: unknown) { + this.start(this.world.currentScene.entities); + } + } + + #setupRectangle(entity: Rectangle): void { + const data = [ + 0, + 0, // top left corner + entity.width, + 0, // top right corner + 0, + entity.height, // bottom left corner + entity.width, + entity.height, // bottom right corner + ]; + for (let i = 0; i < data.length; i += 2) { + data[i] = (data[i] / this.#canvas.width) * 2 - 1; + data[i + 1] = (data[i + 1] / this.#canvas.height) * -2 + 1; + } + const position = createBuffer(this.#device!, new Float32Array(data)); + const uniforms = new Uniforms2D(this.#device!, this.#layouts!.uniform); + this.#buffers.set(entity.id, { position, uniforms }); + } + + #renderRectangle(entity: Rectangle, renderPass: GPURenderPassEncoder): void { + const buffers = this.#buffers.get(entity.id) as RectangleBuffers; + renderPass.setVertexBuffer(0, buffers.position); + renderPass.setVertexBuffer(1, this.#emptyBuffer!); + + const x = (entity.x / this.#canvas.width) * 2; + const y = (entity.y / this.#canvas.height) * -2; + buffers.uniforms.setPosition(this.#device!, x, y); + buffers.uniforms.setColor(this.#device!, entity.fill); + renderPass.setBindGroup(0, this.#emptyTexture!.bindGroup); + renderPass.setBindGroup(1, buffers.uniforms.bindGroup); + renderPass.draw(4, 1); + } + + #setupImage(entity: Image | AtlasSprite): void { + const x = entity instanceof Image ? 0 : entity.frame.x; + const y = entity instanceof Image ? 0 : entity.frame.y; + const { width, height } = entity instanceof Image ? entity : entity.frame; + + const data = [ + x, + y, + x + width, + y, + x, + y + height, + x + width, + y + height, + ]; + for (let i = 0; i < data.length; i += 2) { + data[i] = data[i] / entity.width; + data[i + 1] = data[i + 1] / entity.height; + } + const coords = createBuffer(this.#device!, new Float32Array(data)); + for (let i = 0; i < data.length; i += 2) { + data[i] = data[i] * entity.width / this.#canvas.width * 2 - 1; + data[i + 1] = data[i + 1] * entity.height / this.#canvas.height * -2 + 1; + } + const position = createBuffer(this.#device!, new Float32Array(data)); + const tex2d = loadTexture(this.#device!, (entity as any).bitmap); + const uniforms = new Uniforms2D(this.#device!, this.#layouts!.uniform); + const texture = new Textures2D( + this.#device!, + this.#layouts!.texture, + tex2d, + this.#sampler!, + ); + this.#buffers.set(entity.id, { position, texture, coords, uniforms }); + } + + #renderImage( + entity: Image | AtlasSprite, + renderPass: GPURenderPassEncoder, + ): void { + const buffers = this.#buffers.get(entity.id) as ImageBuffers; + renderPass.setVertexBuffer(0, buffers.position); + renderPass.setVertexBuffer(1, buffers.coords); + + const x = entity.x / this.#canvas.width * 2; + const y = entity.y / this.#canvas.height * -2; + + buffers.uniforms.setUsage(this.#device!, 1); + buffers.uniforms.setColor(this.#device!, [1, 0, 0, 1]); + buffers.uniforms.setPosition(this.#device!, x, y); + renderPass.setBindGroup(0, buffers.texture.bindGroup); + renderPass.setBindGroup(1, buffers.uniforms.bindGroup); + renderPass.draw(4, 1); + } + + setBackground(color: RGBA | string) { + this.#backgroundColor = typeof color === "string" + ? hexToRGBA(color) + : color; + } +} diff --git a/src/renderers/webgpu/shader2d.ts b/src/renderers/webgpu/shader2d.ts new file mode 100644 index 0000000..3230be6 --- /dev/null +++ b/src/renderers/webgpu/shader2d.ts @@ -0,0 +1,232 @@ +import { RGBA } from "../../types.ts"; +import { Usage } from "./types.ts"; + +export const shader2d = ` +struct Uniforms { + position: vec2, + usage: f32, + color: vec4, +}; + +struct Output { + @builtin(position) position: vec4, + @location(1) coords: vec2, +}; + +@group(0) @binding(0) +var uTexture: texture_2d; +@group(0) @binding(1) +var uSampler: sampler; + +@group(1) @binding(0) +var uniforms: Uniforms; + +@vertex +fn vs_main( + @location(0) position: vec2, + @location(1) coords: vec2, +) -> Output { + var out: Output; + out.position = vec4(position + uniforms.position, 0.0, 1.0); + out.coords = coords; + return out; +} + +@fragment +fn fs_main(out: Output) -> @location(0) vec4 { + if (uniforms.usage == 0.0) { + return uniforms.color; + } else { + return textureSample(uTexture, uSampler, out.coords); + } +} +`; + +export const mipMapShader = ` +struct VertexOutput { + @builtin(position) position : vec4, + @location(0) texCoord : vec2, +}; + +var pos : array, 4> = array, 4>( + vec2(-1.0, 1.0), vec2(1.0, 1.0), + vec2(-1.0, -1.0), vec2(1.0, -1.0)); + +@vertex +fn vertexMain(@builtin(vertex_index) vertexIndex : u32) -> VertexOutput { + var output : VertexOutput; + output.texCoord = pos[vertexIndex] * vec2(0.5, -0.5) + vec2(0.5); + output.position = vec4(pos[vertexIndex], 0.0, 1.0); + return output; +} + +@binding(0) @group(0) +var imgSampler : sampler; +@binding(1) @group(0) +var img : texture_2d; + +@fragment +fn fragmentMain(@location(0) texCoord : vec2) -> @location(0) vec4 { + return textureSample(img, imgSampler, texCoord); +} +`; + +export const bindGroupUniform2d: GPUBindGroupLayoutDescriptor = { + entries: [ + { + binding: 0, // all uniforms + visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, + buffer: { + type: "uniform", + minBindingSize: 32, + }, + }, + ], +}; + +export const bindGroupTexture2d: GPUBindGroupLayoutDescriptor = { + entries: [ + { + binding: 0, // texture + visibility: GPUShaderStage.FRAGMENT, + texture: {}, + }, + { + binding: 1, // sampler + visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, + sampler: {}, + }, + ], +}; + +export function createBindGroupLayout(device: GPUDevice) { + return { + uniform: device.createBindGroupLayout(bindGroupUniform2d), + texture: device.createBindGroupLayout(bindGroupTexture2d), + }; +} + +// https://github.com/denoland/webgpu-examples/blob/main/boids/mod.ts + +export function createRenderPipeline( + device: GPUDevice, + module: GPUShaderModule, + layout: GPUPipelineLayout, + presentationFormat: GPUTextureFormat, +) { + const vertexBuffers: GPUVertexBufferLayout[] = [ + { + arrayStride: 2 * 4, + attributes: [ + { + format: "float32x2", + offset: 0, + shaderLocation: 0, + }, + ], + }, + { + arrayStride: 2 * 4, + attributes: [ + { + format: "float32x2", + offset: 0, + shaderLocation: 1, + }, + ], + }, + ]; + const pipeline = device.createRenderPipeline({ + layout, + vertex: { + module, + entryPoint: "vs_main", + buffers: vertexBuffers, + }, + fragment: { + module, + entryPoint: "fs_main", + targets: [ + { + format: presentationFormat, + }, + ], + }, + primitive: { + topology: "triangle-strip", + }, + }); + return pipeline; +} + +export class Uniforms2D { + buffer: GPUBuffer; + bindGroup: GPUBindGroup; + constructor(device: GPUDevice, layout: GPUBindGroupLayout) { + this.buffer = device.createBuffer({ + size: 32, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + this.bindGroup = device.createBindGroup({ + layout, + entries: [{ binding: 0, resource: { buffer: this.buffer } }], + }); + } + + setUsage(device: GPUDevice, usage: Usage) { + const buffer = new Float32Array([usage]); + device.queue.writeBuffer(this.buffer, 8, buffer); + } + + setPosition(device: GPUDevice, x: number, y: number) { + const buffer = new Float32Array([x, y]); + device.queue.writeBuffer(this.buffer, 0, buffer); + } + + setColor(device: GPUDevice, color: [number, number, number, number]) { + const buffer = new Float32Array(color); + device.queue.writeBuffer(this.buffer, 16, buffer); + } + + colorNorm(rgba: RGBA): RGBA { + return rgba.map((c) => c / 255) as RGBA; + } +} + +export class Textures2D { + sampler: GPUSampler; + texture: GPUTexture; + bindGroup: GPUBindGroup; + constructor( + device: GPUDevice, + layout: GPUBindGroupLayout, + texture: GPUTexture, + sampler: GPUSampler, + ) { + this.texture = texture; + this.sampler = sampler; + this.bindGroup = device.createBindGroup({ + layout, + entries: [ + { binding: 0, resource: this.texture.createView() }, + { binding: 1, resource: this.sampler }, + ], + }); + } + + static empty( + device: GPUDevice, + layout: GPUBindGroupLayout, + sampler: GPUSampler, + ) { + const size = { width: 1, height: 1 }; + const texture = device.createTexture({ + size, + format: "rgba8unorm", + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | + GPUTextureUsage.RENDER_ATTACHMENT, + }); + device.queue.writeTexture({ texture }, new Uint8Array(4), {}, size); + return new Textures2D(device, layout, texture, sampler); + } +} diff --git a/src/renderers/webgpu/types.ts b/src/renderers/webgpu/types.ts new file mode 100644 index 0000000..377cea8 --- /dev/null +++ b/src/renderers/webgpu/types.ts @@ -0,0 +1,27 @@ +import { Textures2D, Uniforms2D } from "./shader2d.ts"; + +export type Layouts = { + uniform: GPUBindGroupLayout; + texture: GPUBindGroupLayout; +}; + +export type EntityBuffers = RectangleBuffers | ImageBuffers; + +export type RectangleBuffers = { + uniforms: Uniforms2D; + + position: GPUBuffer; +}; + +export type ImageBuffers = { + uniforms: Uniforms2D; + texture: Textures2D; + + position: GPUBuffer; + coords: GPUBuffer; +}; + +export enum Usage { + Geometry, + Texture, +} diff --git a/src/renderers/webgpu/util.ts b/src/renderers/webgpu/util.ts new file mode 100644 index 0000000..a65c943 --- /dev/null +++ b/src/renderers/webgpu/util.ts @@ -0,0 +1,107 @@ +export function createBuffer(device: GPUDevice, data: BufferSource) { + const buffer = device.createBuffer({ + usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, + size: data.byteLength, + }); + device.queue.writeBuffer(buffer, 0, data); + return buffer; +} +export function loadTexture( + device: GPUDevice, + // @ts-ignore: typescript is weird + source: ImageBitmap, + mipmapShaderModule?: GPUShaderModule, + genMipmap = false, +) { + const size = { width: source.width, height: source.height }; + const descriptor: GPUTextureDescriptor = { + size: size, + format: "rgba8unorm", + dimension: "2d", + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | + GPUTextureUsage.RENDER_ATTACHMENT, + }; + if (genMipmap) { + descriptor.mipLevelCount = + Math.floor(Math.log2(Math.max(source.width, source.height))) + 1; + } + const texture = device.createTexture(descriptor); + + // @ts-ignore: typescript is weird + device.queue.copyExternalImageToTexture({ source }, { texture }, size); + if (genMipmap) generateMipmap(device, texture, descriptor, mipmapShaderModule!); + + return texture; +} +function generateMipmap( + device: GPUDevice, + texture: GPUTexture, + textureDescriptor: GPUTextureDescriptor, + mipmapShaderModule: GPUShaderModule, +) { + + const pipeline = device.createRenderPipeline({ + // @ts-ignore: typescript is weird + layout: "auto", + vertex: { + module: mipmapShaderModule, + entryPoint: "vertexMain", + }, + fragment: { + module: mipmapShaderModule, + entryPoint: "fragmentMain", + targets: [{ + format: textureDescriptor.format, + }], + }, + primitive: { + topology: "triangle-strip", + stripIndexFormat: "uint32", + }, + }); + + const sampler = device.createSampler({ minFilter: "linear" }); + + let srcView = texture.createView({ + baseMipLevel: 0, + mipLevelCount: 1, + }); + + const commandEncoder = device.createCommandEncoder({}); + for (let i = 1; i < textureDescriptor.mipLevelCount!; ++i) { + const dstView = texture.createView({ + baseMipLevel: i, + mipLevelCount: 1, + }); + + const passEncoder = commandEncoder.beginRenderPass({ + colorAttachments: [{ + view: dstView, + // @ts-ignore clear not recognized or something + loadOp: "clear", + clearValue: [0, 0, 0, 0], + storeOp: "store", + }], + }); + + const bindGroup = device.createBindGroup({ + layout: pipeline.getBindGroupLayout(0), + entries: [{ + binding: 0, + resource: sampler, + }, { + binding: 1, + resource: srcView, + }], + }); + + passEncoder.setPipeline(pipeline); + passEncoder.setBindGroup(0, bindGroup); + passEncoder.draw(4); + // @ts-ignore: typescript is weird + passEncoder.end(); + + srcView = dstView; + } + device.queue.submit([commandEncoder.finish()]); +} diff --git a/src/scenes/Scene.ts b/src/scenes/Scene.ts index b8631ce..9f2fb35 100644 --- a/src/scenes/Scene.ts +++ b/src/scenes/Scene.ts @@ -6,7 +6,7 @@ import { Sprite, World, } from "../../mod.ts"; -import type { KeyEvent, MouseDownEvent, MouseMotionEvent } from "../types.ts"; +import type { KeyEvent, MouseDownEvent, MouseMotionEvent, RGBA } from "../types.ts"; export type Resource = Image | AtlasSprite | Sprite; diff --git a/src/utils/mod.ts b/src/utils/mod.ts index 64a2c4b..f599445 100644 --- a/src/utils/mod.ts +++ b/src/utils/mod.ts @@ -7,7 +7,7 @@ export { sleepSync } from "./system.ts"; export const printBanner = (version: string) => { console.log.apply(globalThis.console, [ - "\n %c %c %c Caviar " + "v." + version + " - 🚀" + "WebGPU" + + "\n %c %c %c Caviar " + "v." + version + " - 🚀" + "WebGL" + " %c %c https://github.com/load1n9/caviar", "background: #d48e1e; padding:5px 0;", "background: #e67615; padding:5px 0;", diff --git a/src/version.ts b/src/version.ts index 24ca894..2bffd64 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "2.5.4"; \ No newline at end of file +export const VERSION = "2.5.5"; \ No newline at end of file diff --git a/web/src/version.ts b/web/src/version.ts index 24ca894..2bffd64 100644 --- a/web/src/version.ts +++ b/web/src/version.ts @@ -1 +1 @@ -export const VERSION = "2.5.4"; \ No newline at end of file +export const VERSION = "2.5.5"; \ No newline at end of file