├── public ├── favicon.ico ├── logo-dark.png ├── logo-light.png └── img │ └── texture.png ├── app ├── globals.d.ts ├── cameras │ ├── readme.md │ ├── meshes │ │ ├── plane.ts │ │ └── cube.ts │ ├── plane.wgsl │ ├── cube.wgsl │ ├── input.ts │ ├── main.js │ └── camera.ts ├── tailwind.css ├── editor │ ├── inspector.module.css │ ├── inspector.tsx │ └── editor.tsx ├── entry.worker.ts ├── keybind.ts ├── entry.client.tsx ├── node-graph │ ├── graph.module.css │ ├── grid.ts │ └── graph.tsx ├── intl.tsx ├── resizer-observer.ts ├── main.css ├── text.ts ├── entry.server.tsx ├── routes │ └── _index.tsx └── root.tsx ├── .gitignore ├── postcss.config.js ├── tailwind.config.ts ├── .vscode └── settings.json ├── server.js ├── tsconfig.json ├── rect-project ├── CMakeLists.txt └── main.cc ├── vite.config.ts ├── README.md ├── package.json └── .eslintrc.cjs /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourWaifu/pwa-experiment/main/public/favicon.ico -------------------------------------------------------------------------------- /app/globals.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.css?url'; 2 | declare module '*.css'; 3 | declare module '*.wgsl'; -------------------------------------------------------------------------------- /public/logo-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourWaifu/pwa-experiment/main/public/logo-dark.png -------------------------------------------------------------------------------- /public/logo-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourWaifu/pwa-experiment/main/public/logo-light.png -------------------------------------------------------------------------------- /public/img/texture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yourWaifu/pwa-experiment/main/public/img/texture.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | .env 6 | rect-project/build 7 | public/build 8 | app/cpp-build -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /app/cameras/readme.md: -------------------------------------------------------------------------------- 1 | Most of the code in this folder wasn't written by me. I have made some edits with how the texture is loaded, the texture, and the model's UV. -------------------------------------------------------------------------------- /app/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, 6 | body { 7 | @apply bg-white dark:bg-gray-950; 8 | 9 | @media (prefers-color-scheme: dark) { 10 | color-scheme: dark; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /app/editor/inspector.module.css: -------------------------------------------------------------------------------- 1 | .property { 2 | display: flex; 3 | } 4 | 5 | .label { 6 | flex: 0 1 33.33%; 7 | } 8 | 9 | .number { 10 | background-color: oklch(68.47% 0.1479 237.32 / 0.33); 11 | padding-left: 2px; 12 | flex: 0 1 5em; 13 | margin-left: 2px; 14 | margin-bottom: 2px; 15 | border-radius: 3px; 16 | } -------------------------------------------------------------------------------- /app/editor/inspector.tsx: -------------------------------------------------------------------------------- 1 | import {atom, useAtom} from "jotai"; 2 | import { FormattedMessage } from "../intl"; 3 | 4 | export const inspectorDataAtom = atom<{ 5 | index: number | null, 6 | message: JSX.Element 7 | }>({ 8 | index: null, 9 | message: 10 | }); 11 | 12 | -------------------------------------------------------------------------------- /app/cameras/meshes/plane.ts: -------------------------------------------------------------------------------- 1 | // prettier-ignore 2 | export const planeVertexArray = new Float32Array([ 3 | // float4 position, float4 color, float2 uv, 4 | 1, -1, 1, 1, 1, 0, 1, 1, 0, 1, 5 | -1, -1, 1, 1, 0, 0, 1, 1, 1, 1, 6 | -1, -1, -1, 1, 0, 0, 0, 1, 1, 0, 7 | 1, -1, -1, 1, 1, 0, 0, 1, 0, 0, 8 | 1, -1, 1, 1, 1, 0, 1, 1, 0, 1, 9 | -1, -1, -1, 1, 0, 0, 0, 1, 1, 0, 10 | 11 | ]); -------------------------------------------------------------------------------- /app/entry.worker.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | export {}; 4 | 5 | declare let self: ServiceWorkerGlobalScope; 6 | 7 | self.addEventListener('install', event => { 8 | console.log('Service worker installed'); 9 | 10 | event.waitUntil(self.skipWaiting()); 11 | }); 12 | 13 | self.addEventListener('activate', event => { 14 | console.log('Service worker activated'); 15 | 16 | event.waitUntil(self.clients.claim()); 17 | }); 18 | -------------------------------------------------------------------------------- /app/keybind.ts: -------------------------------------------------------------------------------- 1 | let isShiftKeyDown: boolean = false; 2 | export const getShiftKeyDown = () => isShiftKeyDown; 3 | let isCtrlKeyDown: boolean = false; 4 | export const getCtrlKeyDown = () => isCtrlKeyDown; 5 | 6 | function onKey(event: React.KeyboardEvent) { 7 | isShiftKeyDown = event.shiftKey; 8 | isCtrlKeyDown = event.ctrlKey; 9 | } 10 | 11 | export function onKeydown(event: React.KeyboardEvent) { 12 | onKey(event); 13 | } 14 | export function onKeyUp(event: React.KeyboardEvent) { 15 | onKey(event); 16 | } -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | export default { 4 | content: ["./app/**/{**,.client,.server}/**/*.{js,jsx,ts,tsx}"], 5 | theme: { 6 | extend: { 7 | fontFamily: { 8 | sans: [ 9 | "Inter", 10 | "ui-sans-serif", 11 | "system-ui", 12 | "sans-serif", 13 | "Apple Color Emoji", 14 | "Segoe UI Emoji", 15 | "Segoe UI Symbol", 16 | "Noto Color Emoji", 17 | ], 18 | }, 19 | }, 20 | }, 21 | plugins: [], 22 | } satisfies Config; 23 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle hydrating your app on the client for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/file-conventions/entry.client 5 | */ 6 | 7 | import { RemixBrowser } from "@remix-run/react"; 8 | import { startTransition, StrictMode } from "react"; 9 | import { hydrateRoot } from "react-dom/client"; 10 | 11 | startTransition(() => { 12 | hydrateRoot( 13 | document, 14 | 15 | 16 | 17 | ); 18 | }); 19 | -------------------------------------------------------------------------------- /app/cameras/plane.wgsl: -------------------------------------------------------------------------------- 1 | struct Uniforms { 2 | 3 | } 4 | 5 | @group(0) @binding(0) var uniforms : Uniforms; 6 | @group(0) @binding(1) var mySampler: sampler; 7 | @group(0) @binding(2) var myTexture: texture_2d; 8 | 9 | struct VertexOutput { 10 | @builtin(position) Position : vec4f, 11 | @location(0) fragUV : vec2f, 12 | } 13 | 14 | @vertex 15 | fn vertex_main( 16 | @location(0) position : vec4f, 17 | @location(1) uv : vec2f 18 | ) -> VertexOutput { 19 | return VertexOutput(position, uv); 20 | } 21 | 22 | @fragment 23 | fn fragment_main(@location(0) fragUV: vec2f) -> @location(0) vec4f { 24 | return textureSample(myTexture, mySampler, fragUV); 25 | } -------------------------------------------------------------------------------- /app/node-graph/graph.module.css: -------------------------------------------------------------------------------- 1 | .graph { 2 | overflow: hidden; 3 | position: relative; 4 | touch-action: none; 5 | } 6 | 7 | .nodeContainer { 8 | width: fit-content; 9 | height: fit-content; 10 | position: absolute; 11 | } 12 | 13 | .node { 14 | transform: translate(-50%, 100%); 15 | display: flex; 16 | flex-direction: column; 17 | width: fit-content; 18 | height: fit-content; 19 | background-color: oklch(85% 0.09 186.4); 20 | } 21 | 22 | .input { 23 | display: flex; 24 | flex-direction: row; 25 | text-align: start; 26 | } 27 | 28 | .output { 29 | display: flex; 30 | flex-direction: row-reverse; 31 | text-align: end; 32 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "compare": "cpp", 4 | "concepts": "cpp", 5 | "cstddef": "cpp", 6 | "cstdint": "cpp", 7 | "cstdio": "cpp", 8 | "cstdlib": "cpp", 9 | "cstring": "cpp", 10 | "cwchar": "cpp", 11 | "exception": "cpp", 12 | "initializer_list": "cpp", 13 | "limits": "cpp", 14 | "list": "cpp", 15 | "new": "cpp", 16 | "tuple": "cpp", 17 | "type_traits": "cpp", 18 | "utility": "cpp", 19 | "vector": "cpp", 20 | "xmemory": "cpp", 21 | "xtr1common": "cpp", 22 | "xutility": "cpp" 23 | } 24 | } -------------------------------------------------------------------------------- /app/intl.tsx: -------------------------------------------------------------------------------- 1 | import { createIntl, createIntlCache, defineMessage, IntlShape } from "@formatjs/intl"; 2 | import { atom, useAtom } from "jotai"; 3 | import { text } from "./text"; 4 | 5 | const intlAtom = atom>(); 6 | 7 | export function generateIntl(locale: string) { 8 | const cache = createIntlCache(); // optional but presents memory leaks 9 | return createIntl( 10 | { 11 | locale: locale, 12 | messages: text[locale], 13 | }, 14 | cache 15 | ); 16 | } 17 | 18 | export function FormattedMessage(data: any) { 19 | const [intl, setIntl] = useAtom(intlAtom); 20 | return <>{ 21 | intl?.formatMessage(defineMessage(data)) 22 | } 23 | } 24 | 25 | export { intlAtom }; -------------------------------------------------------------------------------- /app/cameras/cube.wgsl: -------------------------------------------------------------------------------- 1 | struct Uniforms { 2 | modelViewProjectionMatrix : mat4x4, 3 | } 4 | 5 | @group(0) @binding(0) var uniforms : Uniforms; 6 | @group(0) @binding(1) var mySampler: sampler; 7 | @group(0) @binding(2) var myTexture: texture_2d; 8 | 9 | struct VertexOutput { 10 | @builtin(position) Position : vec4f, 11 | @location(0) fragUV : vec2f, 12 | } 13 | 14 | @vertex 15 | fn vertex_main( 16 | @location(0) position : vec4f, 17 | @location(1) uv : vec2f 18 | ) -> VertexOutput { 19 | return VertexOutput(uniforms.modelViewProjectionMatrix * position, uv); 20 | } 21 | 22 | @fragment 23 | fn fragment_main(@location(0) fragUV: vec2f) -> @location(0) vec4f { 24 | return textureSample(myTexture, mySampler, fragUV); 25 | } -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | import Koa from "koa"; 2 | import KoaServe from "koa-static"; 3 | import compress from "koa-compress" 4 | import appRoot from "app-root-path"; 5 | import path from "node:path"; 6 | import {createRequestHandler} from "remix-koa-adapter"; 7 | 8 | const app = new Koa(); 9 | const rootPath = appRoot.path; 10 | const buildPath = path.join(rootPath, "build"); 11 | 12 | app.use(compress({ 13 | threshold: 1024, 14 | defaultEncoding: "br", 15 | br: {} 16 | })); 17 | app.use(KoaServe(path.join(buildPath, "client"))); 18 | app.use(createRequestHandler({ 19 | build: await import(`file://${path.join(buildPath, "server/index.js")}`) 20 | })); 21 | 22 | console.log("web server stated. go to http://localhost:3000"); 23 | app.listen(3000); -------------------------------------------------------------------------------- /app/resizer-observer.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export function useResizeObserver( 4 | target: React.RefObject, 5 | resize: ((w: number, h: number) => void) | undefined 6 | ) { 7 | React.useEffect(() => { 8 | if (!target.current) { 9 | return; 10 | } 11 | const observer = new ResizeObserver(([entry]) => { 12 | let width = entry.devicePixelContentBoxSize[0].inlineSize ?? 13 | entry.contentBoxSize[0].inlineSize * devicePixelRatio; 14 | let height = entry.devicePixelContentBoxSize[0].blockSize ?? 15 | entry.contentBoxSize[0].blockSize * devicePixelRatio; 16 | resize?.(width, height); 17 | }); 18 | observer.observe(target.current); 19 | }, [target.current, resize]); 20 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "**/*.ts", 4 | "**/*.tsx", 5 | "**/.server/**/*.ts", 6 | "**/.server/**/*.tsx", 7 | "**/.client/**/*.ts", 8 | "**/.client/**/*.tsx" 9 | , "server.js", "app/cameras/main.js" ], 10 | "compilerOptions": { 11 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 12 | "types": ["@remix-run/node", "vite/client", "@webgpu/types"], 13 | "isolatedModules": true, 14 | "esModuleInterop": true, 15 | "jsx": "react-jsx", 16 | "module": "ESNext", 17 | "moduleResolution": "Bundler", 18 | "resolveJsonModule": true, 19 | "target": "ES2022", 20 | "strict": true, 21 | "allowJs": true, 22 | "skipLibCheck": true, 23 | "forceConsistentCasingInFileNames": true, 24 | "baseUrl": ".", 25 | "paths": { 26 | "~/*": ["./app/*"] 27 | }, 28 | 29 | // Vite takes care of building everything, not tsc. 30 | "noEmit": true 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /rect-project/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required (VERSION 3.6) 2 | 3 | project(rect-project) 4 | 5 | add_executable(rect-project main.cc) 6 | set_target_properties (rect-project PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/${CMAKE_BUILD_TYPE}") 7 | #target_link_options (rect-project PUBLIC "-sEXPORTED_FUNCTIONS=[\"_getPlaneVertex\"]") 8 | target_link_options (rect-project PUBLIC "-sEXTRA_EXPORTED_RUNTIME_METHODS=[\"ccall\", \"cwrap\"]") 9 | target_link_options (rect-project PUBLIC -sALLOW_MEMORY_GROWTH=1 --no-heap-copy --bind -s EXPORT_ES6=1) 10 | set (WebSiteSourcesFolder ../app/cpp-build) 11 | get_filename_component (WebSiteSourcesFolderFullPath "${WebSiteSourcesFolder}" ABSOLUTE) 12 | 13 | add_custom_command(TARGET rect-project POST_BUILD 14 | COMMAND ${CMAKE_COMMAND} -E copy_directory $ "${WebSiteSourcesFolderFullPath}" 15 | COMMENT "Copying output to public folder" 16 | ) -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { vitePlugin as remix } from "@remix-run/dev"; 2 | import { defineConfig } from "vite"; 3 | import tsconfigPaths from "vite-tsconfig-paths"; 4 | import { remixPWA } from "@remix-pwa/dev"; 5 | import vitePluginString from "vite-plugin-string"; 6 | 7 | declare module "@remix-run/node" { 8 | interface Future { 9 | v3_singleFetch: true; 10 | } 11 | } 12 | 13 | export default defineConfig({ 14 | plugins: [ 15 | remix({ 16 | future: { 17 | v3_fetcherPersist: true, 18 | v3_relativeSplatPath: true, 19 | v3_throwAbortReason: true, 20 | v3_singleFetch: true, 21 | v3_lazyRouteDiscovery: true, 22 | }, 23 | }), 24 | remixPWA(), 25 | vitePluginString({ 26 | include: ["**/*.txt", "**/*.glsl", "**/*.wgsl"], 27 | compress: false 28 | }), 29 | tsconfigPaths(), 30 | ], 31 | css: { 32 | modules: { 33 | localsConvention: "camelCase" 34 | } 35 | } 36 | }); 37 | -------------------------------------------------------------------------------- /app/node-graph/grid.ts: -------------------------------------------------------------------------------- 1 | import { atom, useAtom } from "jotai"; 2 | export const viewTransformAtom = atom({x: 0, y: 0, z: 1}); 3 | 4 | // The reason this is a function is because the style is dynamic 5 | export function gridStyle(): React.CSSProperties { 6 | const [viewTransform, setViewTransform] = useAtom(viewTransformAtom); 7 | const scale = viewTransform.z; 8 | const size = 50 * scale; 9 | const thickness = 1 * scale; 10 | return { 11 | backgroundSize: `${size}px ${size}px`, 12 | backgroundImage: `linear-gradient(to right, transparent ${(size-thickness)/2}px, oklch(0.7567 0 147.18) ${(size/2)-thickness}px, oklch(0.7567 0 147.18) ${(size+thickness)/2}px, transparent ${(size+thickness)/2}px), linear-gradient(transparent ${(size-thickness)/2}px, oklch(0.7567 0 147.18) ${(size/2)-thickness}px, oklch(0.7567 0 147.18) ${(size+thickness)/2}px, transparent ${(size+thickness)/2}px)`, 13 | position: "absolute", 14 | top: 0, 15 | bottom: 0, 16 | left: 0, 17 | right: 0, 18 | backgroundPosition: `calc(50% + ${viewTransform.x * scale}px ) calc(50% - ${viewTransform.y * scale }px)`, 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /app/main.css: -------------------------------------------------------------------------------- 1 | .titleBar { 2 | display: flex; flex-direction: row; 3 | width: 100%; 4 | -webkit-user-select: none; 5 | user-select: none; 6 | } 7 | 8 | .titleBarStart { 9 | flex: 1 1 0; 10 | display: flex; 11 | justify-content: flex-start; 12 | } 13 | 14 | .titleBarDragArea { 15 | flex: 1; 16 | app-region: drag; 17 | } 18 | 19 | .titleBarCenterText { 20 | text-align: center; 21 | align-self: center; 22 | } 23 | 24 | .titleBarEnd { 25 | flex: 1 1 0; 26 | text-align: right; 27 | display: flex; 28 | justify-content: flex-end; 29 | height: 100%; 30 | } 31 | 32 | .viewport { 33 | overflow: hidden; 34 | flex-grow: 1; 35 | } 36 | 37 | .sidePanel { 38 | width: 300px; 39 | } 40 | 41 | .app { 42 | display: flex; 43 | flex-direction: column; 44 | height: 100vh; 45 | overflow: hidden; 46 | } 47 | 48 | .rowPanels { 49 | display: flex; 50 | flex-direction: row; 51 | flex-grow: 1; 52 | overflow: hidden; 53 | } 54 | 55 | .columnPanels { 56 | display: flex; 57 | flex-direction: column; 58 | flex-grow: 1; 59 | overflow: auto; 60 | } 61 | 62 | .visualProgrammingEditor { 63 | flex-basis: 300px; 64 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pwa experiment 2 | 3 | a experiment I did a year ago to port an app to web APIs 4 | 5 | ``` 6 | cd emsdk 7 | .\emsdk activate latest 8 | cd .. 9 | cd pwa-experiment 10 | npm install 11 | cd rect-project 12 | mkdir build 13 | cd build 14 | emcmake cmake .. 15 | cmake --build . 16 | cd ../.. 17 | npm run build 18 | ``` 19 | 20 | In another terminal: 21 | 22 | ``` 23 | node server.js 24 | ``` 25 | 26 | ## Run in Web 27 | 28 | ### Prerequisites 29 | 30 | a broswer with webGPU support, [here's a list of them: https://developer.mozilla.org/en-US/docs/Web/API/WebGPU_API#browser_compatibility](https://developer.mozilla.org/en-US/docs/Web/API/WebGPU_API#browser_compatibility). 31 | 32 | Some of them require enabling webGPU, often as a flag or a option in the settings. 33 | 34 | * [Chrome & Edge: https://developer.chrome.com/blog/new-in-webgpu-113/#enable_the_feature](https://developer.chrome.com/blog/new-in-webgpu-113/#enable_the_feature) 35 | * [Firefox: https://stackoverflow.com/questions/73706354/how-to-try-webgpu-in-firefox-nightly-now-in-fall-of-2022](https://stackoverflow.com/questions/73706354/how-to-try-webgpu-in-firefox-nightly-now-in-fall-of-2022) 36 | 37 | [Test it by loading up a sample: https://webgpu.github.io/webgpu-samples/](https://webgpu.github.io/webgpu-samples/) 38 | 39 | ## Run 40 | 41 | ``` 42 | npm install 43 | npm run build 44 | node server.js 45 | ``` 46 | 47 | it should say that the web server started and display a URL. Open that URL in your broswer. -------------------------------------------------------------------------------- /rect-project/main.cc: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | 7 | struct Rect { 8 | public: 9 | Rect(float _x, float _y, float _width, float _height): 10 | x(_x), y(_y), width(_width), height(_height) 11 | { 12 | 13 | } 14 | ~Rect() { 15 | 16 | } 17 | 18 | float x; 19 | float y; 20 | float width; 21 | float height; 22 | }; 23 | 24 | float randomFloat(float min, float max) { 25 | return min + (static_cast(rand()) / static_cast(RAND_MAX)) * (max - min); 26 | } 27 | 28 | emscripten::val getRectList(int seed) { 29 | std::list rectList{}; 30 | srand(seed); 31 | const float minPos = -5.0; 32 | const float maxCanvasWidth = 700.0; 33 | const float maxCannasHeight = 200.0; 34 | const float minRectSize = 10.0; 35 | const float maxRectSize = 100.0; 36 | for (int i = 0; i < 10; i += 1) { 37 | Rect random{ 38 | randomFloat(minPos, maxCanvasWidth), randomFloat(minPos, maxCannasHeight), 39 | randomFloat(minRectSize, maxRectSize), randomFloat(minRectSize, maxRectSize) 40 | }; 41 | rectList.push_front(random); 42 | } 43 | std::vector rectBuffer; 44 | rectBuffer.reserve(rectList.size() * (sizeof(Rect)/sizeof(float))); 45 | for (Rect& rect : rectList) { 46 | rectBuffer.push_back(rect.x); // copy 47 | rectBuffer.push_back(rect.y); 48 | rectBuffer.push_back(rect.width); 49 | rectBuffer.push_back(rect.height); 50 | } 51 | 52 | return emscripten::val( 53 | emscripten::typed_memory_view(rectBuffer.size(), 54 | (const float *)(rectBuffer.data()) 55 | ) 56 | ); 57 | } 58 | 59 | EMSCRIPTEN_BINDINGS() { 60 | function("getRectList", &getRectList); 61 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "private": true, 4 | "sideEffects": false, 5 | "type": "module", 6 | "scripts": { 7 | "build": "remix vite:build", 8 | "dev": "remix vite:dev", 9 | "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", 10 | "start": "node server.js", 11 | "typecheck": "tsc" 12 | }, 13 | "dependencies": { 14 | "@formatjs/intl": "^2.10.14", 15 | "@remix-pwa/worker-runtime": "^2.1.4", 16 | "@remix-run/node": "^2.14.0", 17 | "@remix-run/react": "^2.14.0", 18 | "@remix-run/serve": "^2.14.0", 19 | "@types/koa-compress": "^4.0.6", 20 | "@webgpu/types": "^0.1.51", 21 | "app-root-path": "^3.1.0", 22 | "isbot": "^4.1.0", 23 | "jotai": "^2.10.1", 24 | "koa": "^2.15.3", 25 | "koa-compress": "^5.1.1", 26 | "koa-static": "^5.0.0", 27 | "react": "^18.2.0", 28 | "react-dom": "^18.2.0", 29 | "remix-koa-adapter": "^2.0.0", 30 | "wgpu-matrix": "^3.3.0" 31 | }, 32 | "devDependencies": { 33 | "@remix-pwa/dev": "^3.1.0", 34 | "@remix-run/dev": "^2.14.0", 35 | "@types/koa": "^2.15.0", 36 | "@types/koa-static": "^4.0.4", 37 | "@types/node": "^22.9.0", 38 | "@types/react": "^18.3.12", 39 | "@types/react-dom": "^18.3.1", 40 | "@typescript-eslint/eslint-plugin": "^6.7.4", 41 | "@typescript-eslint/parser": "^6.7.4", 42 | "autoprefixer": "^10.4.19", 43 | "eslint": "^8.57.1", 44 | "eslint-import-resolver-typescript": "^3.6.1", 45 | "eslint-plugin-formatjs": "^4.13.3", 46 | "eslint-plugin-import": "^2.28.1", 47 | "eslint-plugin-jsx-a11y": "^6.7.1", 48 | "eslint-plugin-react": "^7.33.2", 49 | "eslint-plugin-react-hooks": "^4.6.0", 50 | "postcss": "^8.4.38", 51 | "tailwindcss": "^3.4.4", 52 | "typescript": "^5.1.6", 53 | "vite": "^5.1.0", 54 | "vite-plugin-string": "^1.2.3", 55 | "vite-tsconfig-paths": "^4.2.1" 56 | }, 57 | "engines": { 58 | "node": ">=20.0.0" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /app/cameras/meshes/cube.ts: -------------------------------------------------------------------------------- 1 | export const cubeVertexSize = 4 * 10; // Byte size of one cube vertex. 2 | export const cubePositionOffset = 0; 3 | export const cubeColorOffset = 4 * 4; // Byte offset of cube vertex color attribute. 4 | export const cubeUVOffset = 4 * 8; 5 | export const cubeVertexCount = 36; 6 | 7 | // prettier-ignore 8 | export const cubeVertexArray = new Float32Array([ 9 | // float4 position, float4 color, float2 uv, 10 | 1, -1, 1, 1, 1, 0, 1, 1, (0/8), 1, 11 | -1, -1, 1, 1, 0, 0, 1, 1, (1/8), 1, 12 | -1, -1, -1, 1, 0, 0, 0, 1, (1/8), 0, 13 | 1, -1, -1, 1, 1, 0, 0, 1, (0/8), 0, 14 | 1, -1, 1, 1, 1, 0, 1, 1, (0/8), 1, 15 | -1, -1, -1, 1, 0, 0, 0, 1, (1/8), 0, 16 | 17 | 1, 1, 1, 1, 1, 1, 1, 1, (1/8), 1, 18 | 1, -1, 1, 1, 1, 0, 1, 1, (2/8), 1, 19 | 1, -1, -1, 1, 1, 0, 0, 1, (2/8), 0, 20 | 1, 1, -1, 1, 1, 1, 0, 1, (1/8), 0, 21 | 1, 1, 1, 1, 1, 1, 1, 1, (1/8), 1, 22 | 1, -1, -1, 1, 1, 0, 0, 1, (2/8), 0, 23 | 24 | -1, 1, 1, 1, 0, 1, 1, 1, (2/8), 1, 25 | 1, 1, 1, 1, 1, 1, 1, 1, (3/8), 1, 26 | 1, 1, -1, 1, 1, 1, 0, 1, (3/8), 0, 27 | -1, 1, -1, 1, 0, 1, 0, 1, (2/8), 0, 28 | -1, 1, 1, 1, 0, 1, 1, 1, (2/8), 1, 29 | 1, 1, -1, 1, 1, 1, 0, 1, (3/8), 0, 30 | 31 | -1, -1, 1, 1, 0, 0, 1, 1, (3/8), 1, 32 | -1, 1, 1, 1, 0, 1, 1, 1, (4/8), 1, 33 | -1, 1, -1, 1, 0, 1, 0, 1, (4/8), 0, 34 | -1, -1, -1, 1, 0, 0, 0, 1, (3/8), 0, 35 | -1, -1, 1, 1, 0, 0, 1, 1, (3/8), 1, 36 | -1, 1, -1, 1, 0, 1, 0, 1, (4/8), 0, 37 | 38 | 1, 1, 1, 1, 1, 1, 1, 1, (4/8), 1, 39 | -1, 1, 1, 1, 0, 1, 1, 1, (5/8), 1, 40 | -1, -1, 1, 1, 0, 0, 1, 1, (5/8), 0, 41 | -1, -1, 1, 1, 0, 0, 1, 1, (5/8), 0, 42 | 1, -1, 1, 1, 1, 0, 1, 1, (4/8), 0, 43 | 1, 1, 1, 1, 1, 1, 1, 1, (4/8), 1, 44 | 45 | 1, -1, -1, 1, 1, 0, 0, 1, (5/8), 1, 46 | -1, -1, -1, 1, 0, 0, 0, 1, (6/8), 1, 47 | -1, 1, -1, 1, 0, 1, 0, 1, (6/8), 0, 48 | 1, 1, -1, 1, 1, 1, 0, 1, (5/8), 0, 49 | 1, -1, -1, 1, 1, 0, 0, 1, (5/8), 1, 50 | -1, 1, -1, 1, 0, 1, 0, 1, (6/8), 0, 51 | ]); -------------------------------------------------------------------------------- /app/text.ts: -------------------------------------------------------------------------------- 1 | export const text: Record> = { 2 | 'en-US': { 3 | 'menu-text': 'Menu', 4 | "webGPU-not-avaiable": "WebGPU is not supported on this web browser", 5 | "app-name": "PWA Test App", 6 | "node1-name": "Node", 7 | "node1-output-name": "foo", 8 | "node2-name": "Results", 9 | "node2-input-name": "bar", 10 | "center-label": "Center", 11 | "app-description": "In 2023, I worked on CAD software that used Vulkan and Sciter.js, a native framework. Like React, it used JSX, but other then that, it wasn't alike. With that, I built systems for importing npm packages, bundling, and i18n. Saddly, it's closed source. So, I gave myself only 24 to make this, and I hope it's a good show of my skills in React.", 12 | "2D-canvas-not-available": "2D canvas is not supported on this web browser", 13 | "inspector-empty": "Select something in Editor to inspect it", 14 | }, 15 | "zh-Hans": { 16 | "menu-text": "主菜单", 17 | "webGPU-not-avaiable": "此网络浏览器不支持 WebGPU", 18 | "app-name": "PWA 测试应用", 19 | "node1-name": "节点", 20 | "node1-output-name": "foo 变数", 21 | "node2-name": "结果", 22 | "node2-input-name": "bar 变数", 23 | "center-label": "中心", 24 | "app-description": "2023 年,我开发了一款使用原生框架 Sciter.js 的 CAD 软件。与 React 一样,它也使用了 JSX,但除此之外,两者并不相同。借助它,我构建了用于导入 npm 包、捆绑和 i18n 的系统。遗憾的是,它是闭源的,我只给自己 24 分钟的时间来做这件事,我希望这能很好地展示我在 React 方面的技能。顺便说一句,这是机器翻译的。", 25 | "2D-canvas-not-available": "此网络浏览器不支持 2D 画布", 26 | "inspector-empty": "在编辑器中选择某些内容进行检查", 27 | }, 28 | "zh-Hant": { 29 | "menu-text": "主選單", 30 | "webGPU-not-avaiable": "此網頁瀏覽器不支援 WebGPU", 31 | "app-name": "PWA 測試應用程式", 32 | "node1-name": "節點", 33 | "node1-output-name": "foo 變數", 34 | "node2-name": "結果", 35 | "node2-input-name": "bar 變數", 36 | "center-label": "中心", 37 | "app-description": "2023 年,我開發了使用 Sciter.js(一個原生框架)的 CAD 軟體。與 React 一樣,它也使用 JSX,但除此之外,它並不相似。這樣,我建立了用於匯入 npm 套件、捆綁和 i18n 的系統。遺憾的是,它是閉源的,我只給了自己 24 小時來完成這個,我希望這是我在 React 方面的一個很好的展示。順便說一句,這是機器翻譯的。", 38 | "2D-canvas-not-available": "此 Web 瀏覽器不支援 2D 畫布", 39 | "inspector-empty": "在編輯器中選擇某些內容來檢查它", 40 | } 41 | }; -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * This is intended to be a basic starting point for linting in your app. 3 | * It relies on recommended configs out of the box for simplicity, but you can 4 | * and should modify this configuration to best suit your team's needs. 5 | */ 6 | 7 | /** @type {import('eslint').Linter.Config} */ 8 | module.exports = { 9 | root: true, 10 | parserOptions: { 11 | ecmaVersion: "latest", 12 | sourceType: "module", 13 | ecmaFeatures: { 14 | jsx: true, 15 | }, 16 | }, 17 | env: { 18 | browser: true, 19 | commonjs: true, 20 | es6: true, 21 | }, 22 | ignorePatterns: ["!**/.server", "!**/.client"], 23 | 24 | // Base config 25 | extends: ["eslint:recommended"], 26 | 27 | overrides: [ 28 | // React 29 | { 30 | files: ["**/*.{js,jsx,ts,tsx}"], 31 | plugins: ["react", "jsx-a11y"], 32 | extends: [ 33 | "plugin:react/recommended", 34 | "plugin:react/jsx-runtime", 35 | "plugin:react-hooks/recommended", 36 | "plugin:jsx-a11y/recommended", 37 | ], 38 | settings: { 39 | react: { 40 | version: "detect", 41 | }, 42 | formComponents: ["Form"], 43 | linkComponents: [ 44 | { name: "Link", linkAttribute: "to" }, 45 | { name: "NavLink", linkAttribute: "to" }, 46 | ], 47 | "import/resolver": { 48 | typescript: {}, 49 | }, 50 | }, 51 | }, 52 | 53 | // Typescript 54 | { 55 | files: ["**/*.{ts,tsx}"], 56 | plugins: ["@typescript-eslint", "import"], 57 | parser: "@typescript-eslint/parser", 58 | settings: { 59 | "import/internal-regex": "^~/", 60 | "import/resolver": { 61 | node: { 62 | extensions: [".ts", ".tsx"], 63 | }, 64 | typescript: { 65 | alwaysTryTypes: true, 66 | }, 67 | }, 68 | }, 69 | extends: [ 70 | "plugin:@typescript-eslint/recommended", 71 | "plugin:import/recommended", 72 | "plugin:import/typescript", 73 | ], 74 | }, 75 | 76 | // Node 77 | { 78 | files: [".eslintrc.cjs"], 79 | env: { 80 | node: true, 81 | }, 82 | }, 83 | 84 | // Format js 85 | { 86 | "plugins": ["formatjs"], 87 | "rules": { 88 | "formatjs/no-offset": "error" 89 | } 90 | } 91 | ], 92 | }; 93 | -------------------------------------------------------------------------------- /app/cameras/input.ts: -------------------------------------------------------------------------------- 1 | // Information about this file, used by the sample UI 2 | // export const inputSourceInfo = { 3 | // name: __filename.substring(__dirname.length + 1), 4 | // contents: __SOURCE__, 5 | // }; 6 | 7 | // Input holds as snapshot of input state 8 | /* export default interface Input { 9 | // Digital input (e.g keyboard state) 10 | readonly digital: { 11 | readonly forward: boolean; 12 | readonly backward: boolean; 13 | readonly left: boolean; 14 | readonly right: boolean; 15 | readonly up: boolean; 16 | readonly down: boolean; 17 | }; 18 | // Analog input (e.g mouse, touchscreen) 19 | readonly analog: { 20 | readonly x: number; 21 | readonly y: number; 22 | readonly zoom: number; 23 | readonly touching: boolean; 24 | }; 25 | } */ 26 | 27 | // InputHandler is a function that when called, returns the current Input state. 28 | // export type InputHandler = () => Input; 29 | 30 | // createInputHandler returns an InputHandler by attaching event handlers to the window and canvas. 31 | export function createInputHandler( 32 | window : Window , 33 | canvas : HTMLCanvasElement 34 | )/* : InputHandler */ { 35 | const digital = { 36 | forward: false, 37 | backward: false, 38 | left: false, 39 | right: false, 40 | up: false, 41 | down: false, 42 | }; 43 | const analog = { 44 | x: 0, 45 | y: 0, 46 | zoom: 0, 47 | }; 48 | let mouseDown = false; 49 | 50 | const setDigital = (e : KeyboardEvent , value : boolean ) => { 51 | switch (e.code) { 52 | case 'KeyW': 53 | digital.forward = value; 54 | e.preventDefault(); 55 | e.stopPropagation(); 56 | break; 57 | case 'KeyS': 58 | digital.backward = value; 59 | e.preventDefault(); 60 | e.stopPropagation(); 61 | break; 62 | case 'KeyA': 63 | digital.left = value; 64 | e.preventDefault(); 65 | e.stopPropagation(); 66 | break; 67 | case 'KeyD': 68 | digital.right = value; 69 | e.preventDefault(); 70 | e.stopPropagation(); 71 | break; 72 | case 'Space': 73 | digital.up = value; 74 | e.preventDefault(); 75 | e.stopPropagation(); 76 | break; 77 | case 'ShiftLeft': 78 | case 'ControlLeft': 79 | case 'KeyC': 80 | digital.down = value; 81 | e.preventDefault(); 82 | e.stopPropagation(); 83 | break; 84 | } 85 | }; 86 | 87 | window.addEventListener('keydown', (e) => setDigital(e, true)); 88 | window.addEventListener('keyup', (e) => setDigital(e, false)); 89 | 90 | canvas.style.touchAction = 'pinch-zoom'; 91 | canvas.addEventListener('pointerdown', () => { 92 | mouseDown = true; 93 | }); 94 | canvas.addEventListener('pointerup', () => { 95 | mouseDown = false; 96 | }); 97 | canvas.addEventListener('pointermove', (e) => { 98 | mouseDown = e.pointerType == 'mouse' ? (e.buttons & 1) !== 0 : true; 99 | if (mouseDown) { 100 | analog.x += e.movementX; 101 | analog.y += e.movementY; 102 | } 103 | }); 104 | canvas.addEventListener( 105 | 'wheel', 106 | (e) => { 107 | mouseDown = (e.buttons & 1) !== 0; 108 | if (mouseDown) { 109 | // The scroll value varies substantially between user agents / browsers. 110 | // Just use the sign. 111 | analog.zoom += Math.sign(e.deltaY); 112 | e.preventDefault(); 113 | e.stopPropagation(); 114 | } 115 | }, 116 | { passive: false } 117 | ); 118 | 119 | return () => { 120 | const out = { 121 | digital, 122 | analog: { 123 | x: analog.x, 124 | y: analog.y, 125 | zoom: analog.zoom, 126 | touching: mouseDown, 127 | }, 128 | }; 129 | // Clear the analog values, as these accumulate. 130 | analog.x = 0; 131 | analog.y = 0; 132 | analog.zoom = 0; 133 | return out; 134 | }; 135 | } 136 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle generating the HTTP Response for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/file-conventions/entry.server 5 | */ 6 | 7 | import { PassThrough } from "node:stream"; 8 | 9 | import type { AppLoadContext, EntryContext } from "@remix-run/node"; 10 | import { createReadableStreamFromReadable } from "@remix-run/node"; 11 | import { RemixServer } from "@remix-run/react"; 12 | import { isbot } from "isbot"; 13 | import { renderToPipeableStream } from "react-dom/server"; 14 | 15 | const ABORT_DELAY = 5_000; 16 | 17 | export default function handleRequest( 18 | request: Request, 19 | responseStatusCode: number, 20 | responseHeaders: Headers, 21 | remixContext: EntryContext, 22 | // This is ignored so we can keep it in the template for visibility. Feel 23 | // free to delete this parameter in your app if you're not using it! 24 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 25 | loadContext: AppLoadContext 26 | ) { 27 | return isbot(request.headers.get("user-agent") || "") 28 | ? handleBotRequest( 29 | request, 30 | responseStatusCode, 31 | responseHeaders, 32 | remixContext 33 | ) 34 | : handleBrowserRequest( 35 | request, 36 | responseStatusCode, 37 | responseHeaders, 38 | remixContext 39 | ); 40 | } 41 | 42 | function handleBotRequest( 43 | request: Request, 44 | responseStatusCode: number, 45 | responseHeaders: Headers, 46 | remixContext: EntryContext 47 | ) { 48 | return new Promise((resolve, reject) => { 49 | let shellRendered = false; 50 | const { pipe, abort } = renderToPipeableStream( 51 | , 56 | { 57 | onAllReady() { 58 | shellRendered = true; 59 | const body = new PassThrough(); 60 | const stream = createReadableStreamFromReadable(body); 61 | 62 | responseHeaders.set("Content-Type", "text/html"); 63 | 64 | resolve( 65 | new Response(stream, { 66 | headers: responseHeaders, 67 | status: responseStatusCode, 68 | }) 69 | ); 70 | 71 | pipe(body); 72 | }, 73 | onShellError(error: unknown) { 74 | reject(error); 75 | }, 76 | onError(error: unknown) { 77 | responseStatusCode = 500; 78 | // Log streaming rendering errors from inside the shell. Don't log 79 | // errors encountered during initial shell rendering since they'll 80 | // reject and get logged in handleDocumentRequest. 81 | if (shellRendered) { 82 | console.error(error); 83 | } 84 | }, 85 | } 86 | ); 87 | 88 | setTimeout(abort, ABORT_DELAY); 89 | }); 90 | } 91 | 92 | function handleBrowserRequest( 93 | request: Request, 94 | responseStatusCode: number, 95 | responseHeaders: Headers, 96 | remixContext: EntryContext 97 | ) { 98 | return new Promise((resolve, reject) => { 99 | let shellRendered = false; 100 | const { pipe, abort } = renderToPipeableStream( 101 | , 106 | { 107 | onShellReady() { 108 | shellRendered = true; 109 | const body = new PassThrough(); 110 | const stream = createReadableStreamFromReadable(body); 111 | 112 | responseHeaders.set("Content-Type", "text/html"); 113 | 114 | resolve( 115 | new Response(stream, { 116 | headers: responseHeaders, 117 | status: responseStatusCode, 118 | }) 119 | ); 120 | 121 | pipe(body); 122 | }, 123 | onShellError(error: unknown) { 124 | reject(error); 125 | }, 126 | onError(error: unknown) { 127 | responseStatusCode = 500; 128 | // Log streaming rendering errors from inside the shell. Don't log 129 | // errors encountered during initial shell rendering since they'll 130 | // reject and get logged in handleDocumentRequest. 131 | if (shellRendered) { 132 | console.error(error); 133 | } 134 | }, 135 | } 136 | ); 137 | 138 | setTimeout(abort, ABORT_DELAY); 139 | }); 140 | } 141 | -------------------------------------------------------------------------------- /app/editor/editor.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useCallback } from "react"; 3 | import {inspectorDataAtom} from "./inspector"; 4 | import { atom, useSetAtom, useAtom, useAtomValue} from "jotai"; 5 | import {default as createCXX} from "../cpp-build/rect-project"; 6 | import { useResizeObserver } from "../resizer-observer"; 7 | import { FormattedMessage } from "../intl"; 8 | import style from "./inspector.module.css"; 9 | 10 | var CXXPromise: any; 11 | 12 | type AABB = { 13 | ax: number, ay:number, bx:number, by: number, 14 | } 15 | 16 | const rectListAtom = atom([]); 17 | const selectedAtom = atom((get) => get(inspectorDataAtom).index); 18 | 19 | export const Editor = () => { 20 | const [is2DCanvasSupported, setIs2DCanvasSupported] = React.useState(true); 21 | const [size, setSize] = React.useState([300, 300, "100%", "100%"]); 22 | let viewportRef = React.createRef(); // needed for the resize observer 23 | useResizeObserver(viewportRef, (width, height) => { 24 | setSize([width, height, `${width / devicePixelRatio}px`, `${height / devicePixelRatio}px`]); 25 | }); 26 | let onClick = useEditorOnClick(); 27 | const [width, height, widthPX, heightPX] = size; 28 | let refCallback = useEditor(setIs2DCanvasSupported, width, height); 29 | return
30 | { 31 | is2DCanvasSupported ? 36 | :

37 | } 38 |
; 39 | } 40 | 41 | export function useEditor( 42 | setIs2DCanvasSupported: (arg:boolean)=>void, 43 | width: number, height: number, 44 | ) { 45 | const [rectList, setRectList] = useAtom(rectListAtom); 46 | const selectedIndex = useAtomValue(selectedAtom); 47 | let canvasRef = React.useRef(null); 48 | React.useEffect(() => { 49 | if (CXXPromise) {return;} 50 | CXXPromise = createCXX(); 51 | (async () => { 52 | let incomingRectList: AABB[] = []; 53 | let floatList: Float32Array = (await CXXPromise).getRectList(Math.random() * 999999); 54 | for (let i = 0; i < floatList.length; i += 4) { 55 | let [x, y, width, height] = floatList.slice(i, i + 4); 56 | incomingRectList.push({ax: x, ay: y, bx: x + width, by: y + height}); 57 | } 58 | setRectList(incomingRectList); 59 | })(); 60 | }, [setRectList]); 61 | const renderer = useCallback((canvas: HTMLCanvasElement | null) => { 62 | if (canvas === null) { return; } 63 | 64 | if (!canvasRef.current && !canvas.getContext("2d")) { 65 | setIs2DCanvasSupported(false); 66 | return; 67 | } 68 | canvasRef.current = canvas; 69 | renderEditor(canvas, rectList, selectedIndex); 70 | }, [setIs2DCanvasSupported, rectList, selectedIndex, width, height]); 71 | return renderer; 72 | } 73 | 74 | export function renderEditor( 75 | canvas: HTMLCanvasElement, 76 | rectList: AABB[], 77 | selected: number|null 78 | ) { 79 | let ctx = canvas?.getContext("2d"); 80 | if (!ctx) { 81 | throw new Error("getContext 2D failed") 82 | return; 83 | } 84 | ctx.clearRect(0, 0, canvas.width, canvas.height); 85 | const getRect = (aabb: AABB) => { 86 | return [ 87 | aabb.ax * devicePixelRatio, aabb.ay * devicePixelRatio, 88 | (aabb.bx - aabb.ax) * devicePixelRatio, (aabb.by - aabb.ay) * devicePixelRatio 89 | ]; 90 | } 91 | rectList.forEach((aabb, index) => { 92 | const [x, y, w, h] = getRect(aabb); 93 | ctx.fillStyle = "oklch(50.51% 0.1585 30.44)"; 94 | ctx.fillRect(x, y, w, h); 95 | }); 96 | if (selected !== null) { 97 | const [x, y, w, h] = getRect(rectList[selected]); 98 | ctx.strokeStyle = "oklch(19.04% 0.0425 147.77)"; 99 | ctx.lineWidth = 2 * devicePixelRatio; 100 | ctx.strokeRect(x, y, w, h); 101 | } 102 | } 103 | 104 | export function useEditorOnClick(): React.MouseEventHandler { 105 | const setInspectorDataAtom = useSetAtom(inspectorDataAtom); 106 | const rectList = useAtomValue(rectListAtom); 107 | return React.useCallback((event) => { 108 | const viewRect = event.target?.getBoundingClientRect(); 109 | const x = event.clientX - viewRect.left; 110 | const y = event.clientY - viewRect.top; 111 | let clickedRectIndex = rectList.findIndex((rect) => { 112 | // aabb 113 | return rect.ax <= x && rect.ay <= y && x <= rect.bx && y <= rect.by; 114 | }); 115 | if (clickedRectIndex !== -1) { 116 | let rect = rectList[clickedRectIndex]; 117 | setInspectorDataAtom({ 118 | index: clickedRectIndex, 119 | message:
120 |
ax
{rect.ax.toFixed(4)}
121 |
ay
{rect.ay.toFixed(4)}
122 |
bx
{rect.bx.toFixed(4)}
123 |
by
{rect.by.toFixed(4)}
124 |
, 125 | }); 126 | } else { 127 | setInspectorDataAtom({ 128 | index: null, 129 | message:

Null

, 130 | }); 131 | } 132 | }, [rectList, setInspectorDataAtom]) 133 | } -------------------------------------------------------------------------------- /app/routes/_index.tsx: -------------------------------------------------------------------------------- 1 | import type { MetaFunction } from "@remix-run/node"; 2 | 3 | export const meta: MetaFunction = () => { 4 | return [ 5 | { title: "New Remix App" }, 6 | { name: "description", content: "Welcome to Remix!" }, 7 | ]; 8 | }; 9 | 10 | export default function Index() { 11 | return ( 12 |
13 |
14 |
15 |

16 | Welcome to Remix 17 |

18 |
19 | Remix 24 | Remix 29 |
30 |
31 | 51 |
52 |
53 | ); 54 | } 55 | 56 | const resources = [ 57 | { 58 | href: "https://remix.run/start/quickstart", 59 | text: "Quick Start (5 min)", 60 | icon: ( 61 | 69 | 75 | 76 | ), 77 | }, 78 | { 79 | href: "https://remix.run/start/tutorial", 80 | text: "Tutorial (30 min)", 81 | icon: ( 82 | 90 | 96 | 97 | ), 98 | }, 99 | { 100 | href: "https://remix.run/docs", 101 | text: "Remix Docs", 102 | icon: ( 103 | 111 | 116 | 117 | ), 118 | }, 119 | { 120 | href: "https://rmx.as/discord", 121 | text: "Join Discord", 122 | icon: ( 123 | 131 | 135 | 136 | ), 137 | }, 138 | ]; 139 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import { atom, useAtom, useAtomValue } from "jotai"; 2 | import { generateIntl, intlAtom, FormattedMessage } from "./intl.js"; 3 | import { defineMessage, IntlShape } from "@formatjs/intl"; 4 | import * as React from "react"; 5 | import { useState, Suspense } from 'react'; 6 | import { Form, Links, Meta, Outlet, Scripts } from "@remix-run/react"; 7 | import { VisualProgrammingEditor } from "./node-graph/graph"; 8 | import { MetaFunction } from "@remix-run/node/dist/index.js"; 9 | import { onKeydown, onKeyUp } from "./keybind.js"; 10 | import { init as CameraInit } from "./cameras/main"; 11 | import { Editor, renderEditor, useEditorOnClick } from "./editor/editor"; 12 | import { inspectorDataAtom } from "./editor/inspector"; 13 | import { useResizeObserver } from "./resizer-observer.js"; 14 | 15 | // I'm not using CSS-in-JS for preformance reasons, CSS will be in CSS files or inlined 16 | // CSS-in-JS requires the app to be rendered before it knows what the styles are. This 17 | // breaks a few of Remix's optimzations like defer. 18 | // CSS is imported via Vite's CSS bundling as seen below 19 | import "./main.css"; 20 | import "./tailwind.css"; 21 | 22 | const titleMessage = defineMessage({ 23 | id:"app-name", defaultMessage:"PWA test app" 24 | }); 25 | export const meta: MetaFunction = () => { 26 | return [ 27 | { charSet: "utf-8" }, 28 | { title: "PWA experiment" } 29 | ]; 30 | } 31 | 32 | // Language changing 33 | 34 | const localeAtom = atom('en-US', (get, set, action) => { 35 | set(intlAtom, generateIntl(action as string)); 36 | }); 37 | 38 | function useIntlRoot(locale: string) { 39 | const [intl, setIntl] = useAtom(intlAtom); 40 | if (!intl) { 41 | setIntl(generateIntl(locale)); 42 | } 43 | } 44 | 45 | const LocaleChangeButton = ({locale}:{locale: string}) => { 46 | const [currentLocale, setLocale] = useAtom(localeAtom); 47 | const intl = useAtomValue(intlAtom); 48 | return ; 56 | } 57 | 58 | // Top bar 59 | 60 | const TopBar = () => { 61 | return
62 |
63 | 64 | 67 | 68 |
69 |
70 |
71 | 72 |
73 |
74 |
75 |
76 |
77 |
; 78 | } 79 | 80 | // Viewport 81 | 82 | const gpuIsSupportedAtom = atom(true); 83 | 84 | // loading screen for viewport 85 | const ViewportFallback = () => { 86 | return

Loading ...

; 87 | } 88 | 89 | // Only render after hydration 90 | const Viewport = () => { 91 | const [gpuIsSupported, setGpuIsSupported] = useAtom(gpuIsSupportedAtom); 92 | const [canvasError, setCanvasError] = React.useState(""); 93 | const [resize, setResize] = React.useState<{resize: ( w:number, h:number ) => void}>(); 94 | const [{width, height, widthCSS, heightCSS}, setRect] = 95 | React.useState<{width: number, height: number, widthCSS: string, heightCSS:string}>( 96 | {width: 128, height: 128, widthCSS: "100%", heightCSS: "100%"} 97 | ); 98 | 99 | let viewportRef = React.createRef(); // needed for the resize observer 100 | let canvasRef = React.createRef(); // needed for init Camera 101 | useResizeObserver(viewportRef, resize?.resize); 102 | 103 | return
104 | { 105 | gpuIsSupported ? { 107 | if (!canvas || resize) { 108 | return; 109 | } 110 | // Init WebGPU 111 | if (!navigator.gpu) { 112 | setGpuIsSupported(false); 113 | setCanvasError("WebGPU not supported on this broswer"); 114 | } 115 | (async () => { 116 | const adapter = await navigator.gpu.requestAdapter(); 117 | if (!adapter) { 118 | setGpuIsSupported(false); 119 | setCanvasError("request GPU Adapter failed"); 120 | } 121 | 122 | let camera = await CameraInit({canvas}); 123 | console.log("setting setresize") 124 | const resize: ( w:number, h:number ) => void = (width, height) => { 125 | if (isNaN(width) || isNaN(height)) { 126 | return; 127 | } 128 | const {device, resizeCanvas} = camera; 129 | // we want to keep this the same, so we have edit the values directly 130 | setRect({ 131 | width: Math.max(1, Math.min(width, device.limits.maxTextureDimension2D)), 132 | height: Math.max(1, Math.min(height, device.limits.maxTextureDimension2D)), 133 | widthCSS: `${width/devicePixelRatio}px`, 134 | heightCSS: `${height/devicePixelRatio}px` 135 | }); 136 | resizeCanvas(width, height); 137 | }; 138 | // resize has to be an object, otherwise, setResize calls it 139 | setResize({resize}); 140 | })(); 141 | }} 142 | width={width} height={height} 143 | // set width and height for high DPI screens 144 | style={{width: widthCSS, height: heightCSS}} 145 | > 146 | :

147 | 149 |
150 | {canvasError} 151 |

152 | } 153 |
154 | } 155 | 156 | // Node editor 157 | 158 | const NodeEditor = () => { 159 | return 160 | } 161 | 162 | // App 163 | 164 | const SidePanel = () => { 165 | return
166 |
167 | 168 | 169 | 170 |
171 | 172 |
173 | } 174 | 175 | const InspectorPanel = () => { 176 | const inspectorData = useAtomValue(inspectorDataAtom); 177 | 178 | return
179 | {inspectorData.message} 180 |
181 | } 182 | 183 | const Title = () => { 184 | const intl = useAtomValue(intlAtom); 185 | React.useEffect(() => { 186 | // use effect to avoid hydration 187 | if (intl) { 188 | document.title = intl.formatMessage(titleMessage); 189 | } 190 | }, [intl]); 191 | return <>; 192 | } 193 | 194 | export default function App() { 195 | let canvas; 196 | const [locale] = useAtom(localeAtom); 197 | useIntlRoot(locale); 198 | 199 | return ( 200 | 201 | 202 | 203 | 204 | 205 | </head> 206 | <body style={{margin: 0}} onKeyDown={onKeydown} onKeyUp={onKeyUp}> 207 | <Scripts /> 208 | <div className="app"> 209 | <div className="rowPanels" > 210 | <div className="columnPanels" > 211 | <Suspense fallback={<ViewportFallback />}><Viewport /></Suspense> 212 | <Editor /> 213 | <Suspense fallback={<ViewportFallback />}><NodeEditor /></Suspense> 214 | </div> 215 | <div className="columnPanels border-l"> 216 | <SidePanel /> 217 | <InspectorPanel /> 218 | </div> 219 | </div> 220 | </div> 221 | </body> 222 | </html> 223 | ); 224 | } -------------------------------------------------------------------------------- /app/cameras/main.js: -------------------------------------------------------------------------------- 1 | import { mat4, vec3 } from "wgpu-matrix"; 2 | import { 3 | cubeVertexArray, 4 | cubeVertexSize, 5 | cubeUVOffset, 6 | cubePositionOffset, 7 | cubeVertexCount, 8 | } from './meshes/cube'; 9 | import { 10 | planeVertexArray 11 | } from "./meshes/plane" 12 | import cubeWGSL from "./cube.wgsl"; 13 | import planeWGSL from "./plane.wgsl"; 14 | import { ArcballCamera, WASDCamera/* , cameraSourceInfo */ } from './camera'; 15 | import { createInputHandler/* , inputSourceInfo */ } from './input'; 16 | 17 | // code ported from webGPU samples camera 18 | export const init/* : SampleInit */ = async ({ canvas/*, pageState, gui */ }) => { 19 | // if (!pageState.active) { 20 | // return; 21 | // } 22 | 23 | // The input handler 24 | const inputHandler = createInputHandler(window, canvas); 25 | 26 | // The camera types 27 | const initialCameraPosition = vec3.create(3, 2, 5); 28 | const cameras = { 29 | arcball: new ArcballCamera({ position: initialCameraPosition }), 30 | WASD: new WASDCamera({ position: initialCameraPosition }), 31 | }; 32 | 33 | // GUI parameters 34 | const params/* : { type: 'arcball' | 'WASD' } */ = { 35 | type: 'arcball', 36 | }; 37 | 38 | // Callback handler for camera mode 39 | let oldCameraType = params.type; 40 | // gui.add(params, 'type', ['arcball', 'WASD']).onChange(() => { 41 | // // Copy the camera matrix from old to new 42 | // const newCameraType = params.type; 43 | // cameras[newCameraType].matrix = cameras[oldCameraType].matrix; 44 | // oldCameraType = newCameraType; 45 | // }); 46 | 47 | const adapter = await navigator.gpu.requestAdapter(); 48 | if (!adapter) { 49 | throw new Error("GPU requestAdapter failed"); 50 | } 51 | const device = await adapter.requestDevice(); 52 | const context = canvas.getContext('webgpu') /* as GPUCanvasContext */; 53 | 54 | const presentationFormat = navigator.gpu.getPreferredCanvasFormat(); 55 | 56 | context.configure({ 57 | device, 58 | format: presentationFormat, 59 | alphaMode: 'premultiplied', 60 | }); 61 | 62 | // Create a vertex buffer from the cube data. 63 | const verticesBuffer = device.createBuffer({ 64 | size: cubeVertexArray.byteLength, 65 | usage: GPUBufferUsage.VERTEX, 66 | mappedAtCreation: true, 67 | }); 68 | new Float32Array(verticesBuffer.getMappedRange()).set(cubeVertexArray); 69 | verticesBuffer.unmap(); 70 | 71 | const pipeline = device.createRenderPipeline({ 72 | layout: 'auto', 73 | vertex: { 74 | module: device.createShaderModule({ 75 | code: cubeWGSL, 76 | }), 77 | entryPoint: 'vertex_main', 78 | buffers: [ 79 | { 80 | arrayStride: cubeVertexSize, 81 | attributes: [ 82 | { 83 | // position 84 | shaderLocation: 0, 85 | offset: cubePositionOffset, 86 | format: 'float32x4', 87 | }, 88 | { 89 | // uv 90 | shaderLocation: 1, 91 | offset: cubeUVOffset, 92 | format: 'float32x2', 93 | }, 94 | ], 95 | }, 96 | ], 97 | }, 98 | fragment: { 99 | module: device.createShaderModule({ 100 | code: cubeWGSL, 101 | }), 102 | entryPoint: 'fragment_main', 103 | targets: [ 104 | { 105 | format: presentationFormat, 106 | }, 107 | ], 108 | }, 109 | primitive: { 110 | topology: 'triangle-list', 111 | cullMode: 'back', 112 | }, 113 | depthStencil: { 114 | depthWriteEnabled: true, 115 | depthCompare: 'less', 116 | format: 'depth24plus', 117 | }, 118 | }); 119 | 120 | let depthTexture = device.createTexture({ 121 | size: [canvas.width, canvas.height], 122 | format: 'depth24plus', 123 | usage: GPUTextureUsage.RENDER_ATTACHMENT, 124 | }); 125 | 126 | const uniformBufferSize = 4 * 16; // 4x4 matrix 127 | const uniformBuffer = device.createBuffer({ 128 | size: uniformBufferSize, 129 | usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, 130 | }); 131 | 132 | // Fetch the image and upload it into a GPUTexture. 133 | let cubeTexture/* : GPUTexture */; 134 | { 135 | const response = await fetch('/img/texture.png'); 136 | const imageBitmap = await createImageBitmap(await response.blob()); 137 | 138 | cubeTexture = device.createTexture({ 139 | size: [imageBitmap.width, imageBitmap.height, 1], 140 | format: 'rgba8unorm', 141 | usage: 142 | GPUTextureUsage.TEXTURE_BINDING | 143 | GPUTextureUsage.COPY_DST | 144 | GPUTextureUsage.RENDER_ATTACHMENT, 145 | }); 146 | device.queue.copyExternalImageToTexture( 147 | { source: imageBitmap }, 148 | { texture: cubeTexture }, 149 | [imageBitmap.width, imageBitmap.height] 150 | ); 151 | } 152 | 153 | // Create a sampler with linear filtering for smooth interpolation. 154 | const sampler = device.createSampler({ 155 | magFilter: 'linear', 156 | minFilter: 'linear', 157 | }); 158 | 159 | const uniformBindGroup = device.createBindGroup({ 160 | layout: pipeline.getBindGroupLayout(0), 161 | entries: [ 162 | { 163 | binding: 0, 164 | resource: { 165 | buffer: uniformBuffer, 166 | }, 167 | }, 168 | { 169 | binding: 1, 170 | resource: sampler, 171 | }, 172 | { 173 | binding: 2, 174 | resource: cubeTexture.createView(), 175 | }, 176 | ], 177 | }); 178 | 179 | let renderPassDescriptor/* : GPURenderPassDescriptor */= { 180 | colorAttachments: [ 181 | { 182 | view: undefined, // Assigned later 183 | 184 | clearValue: { r: 0.5, g: 0.5, b: 0.5, a: 1.0 }, 185 | loadOp: 'clear', 186 | storeOp: 'store', 187 | }, 188 | ], 189 | depthStencilAttachment: { 190 | view: depthTexture.createView(), 191 | 192 | depthClearValue: 1.0, 193 | depthLoadOp: 'clear', 194 | depthStoreOp: 'store', 195 | }, 196 | }; 197 | 198 | let aspect = canvas.width / canvas.height; 199 | let projectionMatrix = mat4.perspective( 200 | (2 * Math.PI) / 5, 201 | aspect, 202 | 1, 203 | 100.0 204 | ); 205 | 206 | let resolutionChanged = false; 207 | const resizeCanvas = (width, height) => { 208 | resolutionChanged = true; 209 | depthTexture = device.createTexture({ 210 | size: [width, height], 211 | format: 'depth24plus', 212 | usage: GPUTextureUsage.RENDER_ATTACHMENT, 213 | }); 214 | 215 | renderPassDescriptor.depthStencilAttachment.view = depthTexture.createView(); 216 | 217 | aspect = canvas.width / canvas.height; 218 | projectionMatrix = mat4.perspective( 219 | (2 * Math.PI) / 5, 220 | aspect, 221 | 1, 222 | 100.0 223 | ); 224 | } 225 | 226 | const modelViewProjectionMatrix = mat4.create(); 227 | 228 | function getModelViewProjectionMatrix(deltaTime/* : number */) { 229 | const camera = cameras[params.type]; 230 | const viewMatrix = camera.update(deltaTime, inputHandler()); 231 | mat4.multiply(projectionMatrix, viewMatrix, modelViewProjectionMatrix); 232 | return modelViewProjectionMatrix/* as Float32Array */; 233 | } 234 | 235 | let lastFrameMS = Date.now(); 236 | 237 | function frame() { 238 | const now = Date.now(); 239 | const deltaTime = (now - lastFrameMS) / 1000; 240 | lastFrameMS = now; 241 | 242 | // if (!pageState.active) { 243 | // // Sample is no longer the active page. 244 | // return; 245 | // } 246 | 247 | const modelViewProjection = getModelViewProjectionMatrix(deltaTime); 248 | device.queue.writeBuffer( 249 | uniformBuffer, 250 | 0, 251 | modelViewProjection.buffer, 252 | modelViewProjection.byteOffset, 253 | modelViewProjection.byteLength 254 | ); 255 | renderPassDescriptor.colorAttachments[0].view = context 256 | .getCurrentTexture() 257 | .createView(); 258 | 259 | const commandEncoder = device.createCommandEncoder(); 260 | const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); 261 | passEncoder.setPipeline(pipeline); 262 | passEncoder.setBindGroup(0, uniformBindGroup); 263 | passEncoder.setVertexBuffer(0, verticesBuffer); 264 | passEncoder.draw(cubeVertexCount); 265 | passEncoder.end(); 266 | device.queue.submit([commandEncoder.finish()]); 267 | 268 | requestAnimationFrame(frame); 269 | } 270 | requestAnimationFrame(frame); 271 | return {resizeCanvas, device}; 272 | }; -------------------------------------------------------------------------------- /app/node-graph/graph.tsx: -------------------------------------------------------------------------------- 1 | import { Style } from "util"; 2 | import { gridStyle, viewTransformAtom } from "./grid"; 3 | import graphStyle from "./graph.module.css"; 4 | import * as React from "react"; 5 | import { useAtom, useAtomValue } from "jotai"; 6 | import { getShiftKeyDown, getCtrlKeyDown } from "../keybind"; 7 | import { FormattedMessage } from "../intl"; 8 | 9 | const Node = ( 10 | { declearion, style, position }: { 11 | declearion: { 12 | formatedDisplayName: string | React.JSX.Element, 13 | inputs?: [{ key: string, formatedDisplayName: string | React.JSX.Element }], 14 | outputs?: [{ key: string, formatedDisplayName: string | React.JSX.Element }], 15 | }, 16 | style?: React.CSSProperties, 17 | position: { x: number, y: number } 18 | } 19 | ) => { 20 | let commputedStyle = { ...style, left: `${position.x}px`, bottom: `${position.y}px` }; 21 | return <div className={graphStyle.nodeContainer} style={commputedStyle} > 22 | <div className={graphStyle.node} > 23 | <div style={{ textAlign: "center" }}>{declearion.formatedDisplayName}</div> 24 | { 25 | declearion.inputs ? declearion.inputs.map((input) => { 26 | return <div key={input.key} className={graphStyle.input}> 27 | <span>{"[in] "}</span><span>{input.formatedDisplayName}</span> 28 | </div> 29 | }) 30 | : <></> 31 | } 32 | { 33 | declearion.outputs ? declearion.outputs.map((output) => { 34 | return <div key={output.key} className={graphStyle.output} > 35 | <span>{" [out]"}</span><span>{output.formatedDisplayName}</span> 36 | </div>; 37 | }) 38 | : <></> 39 | } 40 | </div> 41 | </div> 42 | } 43 | 44 | export const VisualProgrammingEditor = ({ style }: { style: React.CSSProperties }) => { 45 | const viewTransform = useAtomValue(viewTransformAtom); 46 | 47 | //we need to prevent the default behavior to override them 48 | // but react uses passive events by default, so we need to add via the event handler 49 | let refCallback = useVisualProgrammingEditorInputs(); 50 | 51 | return <div className={graphStyle.graph} style={style} ref={refCallback}> 52 | <div style={gridStyle()}></div> 53 | <div style={{ 54 | left: `calc(50% * ${viewTransform.z})`, 55 | top: `calc(-50% * ${viewTransform.z})`, 56 | transform: `translate(${viewTransform.x * viewTransform.z}px, ${0 - viewTransform.y * viewTransform.z}px) scale(${viewTransform.z})`, 57 | minHeight: "100%", 58 | position: "relative", 59 | }}> 60 | 61 | <Node 62 | declearion={{ 63 | formatedDisplayName: <FormattedMessage id="node1-name" defaultMessage="Node" />, 64 | outputs: [ 65 | { 66 | key: "first", 67 | formatedDisplayName: <FormattedMessage id="node1-output-name" defaultMessage="bar" /> 68 | } 69 | ] 70 | }} 71 | position={{ x: -50, y: 50 }} 72 | /> 73 | 74 | <Node 75 | declearion={{ 76 | formatedDisplayName: <FormattedMessage id="node2-name" defaultMessage="Result" />, 77 | inputs: [ 78 | { 79 | key: "2nd", 80 | formatedDisplayName: <FormattedMessage id="node2-input-name" defaultMessage="foo" /> 81 | } 82 | ] 83 | }} 84 | position={{ x: 50, y: -50 }} 85 | /> 86 | 87 | <Node 88 | declearion={{ 89 | formatedDisplayName: <FormattedMessage id="center-label" defaultMessage="center" />, 90 | }} 91 | position={{ x: 0, y: 0 }} 92 | /> 93 | 94 | <Node declearion={{ formatedDisplayName: "|" }} position={{ x: 200, y: 200 }} /> 95 | 96 | </div> 97 | </div> 98 | } 99 | 100 | const useVisualProgrammingEditorInputs = () => { 101 | const [viewTransform, setViewTransform] = useAtom(viewTransformAtom); 102 | 103 | // store muliple pointers for multi-touch 104 | // use state fixes a bug where it turns empty 105 | const [getEventCache, setEventCache] = React.useState(new Map<number, PointerEvent>([])); 106 | let [initPinchDistance, setInitPinchDistance] = React.useState<number | null>(null); 107 | let [initScale, setInitScale] = React.useState<number>(1); 108 | 109 | let eventCache = new Map(getEventCache); 110 | 111 | // these are functions because I can't decide to go with array or map 112 | // I might switch again, so these are here to make that easier 113 | // remember to remove them once I made up my mind 114 | const findInEventCache = (event: PointerEvent) => { 115 | return eventCache.get(event.pointerId) 116 | }; 117 | const removeFromCache = (event: PointerEvent) => { 118 | return eventCache.delete(event.pointerId); 119 | }; 120 | const addToCache = (event: PointerEvent) => { 121 | eventCache.set(event.pointerId, event); 122 | return event; 123 | } 124 | const setCache = (key:any, event: PointerEvent) => { // useful for array 125 | return addToCache(event); 126 | } 127 | const matchCache = (left: any, index: number) => { 128 | return left.pointerId === Array.from(eventCache.values())[index]?.pointerId; 129 | } 130 | 131 | const onWheel = React.useCallback( 132 | (event: WheelEvent) => { 133 | // scale value is different between user agents, we have to handle that here 134 | const transform = viewTransform; 135 | let scale = transform.z; 136 | if (getCtrlKeyDown()) { // zoom modifier 137 | const sensitivity = 0.1; 138 | scale = transform.z * (1 - (sensitivity * Math.sign(event.deltaY))); 139 | setViewTransform({ 140 | ...transform, 141 | z: scale 142 | }); 143 | } else { 144 | const sensitivity = 25.0; 145 | const delta = { // shift swaps axis 146 | x: !getShiftKeyDown() ? event.deltaX : event.deltaY, 147 | y: !getShiftKeyDown() ? event.deltaY : event.deltaX, 148 | }; 149 | setViewTransform({ 150 | ...transform, 151 | x: transform.x - ((sensitivity * Math.sign(delta.x)) / scale), 152 | y: transform.y + ((sensitivity * Math.sign(delta.y)) / scale), 153 | }); 154 | } 155 | event.preventDefault(); 156 | event.stopPropagation(); 157 | }, 158 | [viewTransform] 159 | ); 160 | 161 | const onPointerdown = React.useCallback((event: PointerEvent) => { 162 | // pen input should be ignored for moving, as pens work very differently 163 | if (event.pointerType === "pen") { 164 | return; 165 | } 166 | event.preventDefault(); 167 | }, []); 168 | 169 | const onPointerup = React.useCallback((event: PointerEvent) => { 170 | if (event.pointerType === "pen") { 171 | return; 172 | } 173 | removeFromCache(event); 174 | setEventCache(eventCache); 175 | event.preventDefault(); 176 | }, [getEventCache]); 177 | 178 | const onPointermove = React.useCallback((event: PointerEvent) => { 179 | // same as before, ignore pen because it's different 180 | if (event.pointerType === "pen") { 181 | console.log("pen"); 182 | return; 183 | } 184 | 185 | // edge case for when mouse moves out of element 186 | let isLeftMouseDown = true; 187 | if (event.pointerType === "mouse") { 188 | isLeftMouseDown = (event.buttons & 1) !== 0; 189 | if (!isLeftMouseDown) { 190 | removeFromCache(event); 191 | setEventCache(eventCache); 192 | return; 193 | } 194 | } 195 | 196 | let found = findInEventCache(event); 197 | if (found === undefined) { 198 | found = addToCache(event); 199 | } 200 | setCache(found, event); 201 | 202 | // zoom gesture check 203 | let scale = viewTransform.z; 204 | if (eventCache.size === 2) { 205 | // calulate distance between touch points 206 | const points = Array.from(eventCache.values()).map(event => { 207 | return { 208 | x: event.clientX, 209 | y: event.clientY, 210 | }; 211 | }); 212 | const box = { 213 | x: points[0].x - points[1].x, 214 | y: points[0].y - points[1].y, 215 | } 216 | // euclidean disteance from Pythagoras 217 | const distance = Math.sqrt(Math.pow(box.x, 2) + Math.pow(box.y, 2)); 218 | if (initPinchDistance === null) { 219 | setInitPinchDistance(distance); 220 | setInitScale(scale); 221 | scale = initScale * distance; 222 | } else { 223 | scale = initScale * (distance / initPinchDistance); 224 | } 225 | } else { 226 | setInitPinchDistance(null); 227 | setInitScale(scale); 228 | } 229 | 230 | let { x, y } = viewTransform; 231 | if (matchCache(event, 0)) { // first pointer, so might be moving around 232 | // this doesn't seem to very accurate, fast movement seems to throw it off 233 | // probably better to get a path from last a moment ago to current point 234 | let deltaMove = { x: event.movementX, y: event.movementY }; 235 | x += deltaMove.x / scale; 236 | y -= deltaMove.y / scale; // web is inverted 237 | } 238 | 239 | setViewTransform({ 240 | x, y, 241 | z: scale, 242 | }); 243 | setEventCache(eventCache); 244 | }, [viewTransform, getEventCache]); 245 | 246 | let ref = React.useRef<HTMLDivElement | null>(null); 247 | return React.useCallback((node: HTMLDivElement | null) => { 248 | if (!node) { 249 | if (ref.current) { 250 | ref.current.removeEventListener("wheel", onWheel); 251 | ref.current.removeEventListener("pointerdown", onPointerdown); 252 | ref.current.removeEventListener("pointerup", onPointerup); 253 | ref.current.removeEventListener("pointermove", onPointermove); 254 | } 255 | return; 256 | } 257 | 258 | ref.current = node; 259 | node.addEventListener("wheel", onWheel, {passive: false}) 260 | node.addEventListener("pointerdown", onPointerdown, {passive: false}); 261 | node.addEventListener("pointerup", onPointerup, {passive: false}); 262 | node.addEventListener("pointermove", onPointermove, {passive: false}); 263 | }, [onWheel, onPointerdown, onPointermove, onPointerup]); 264 | } -------------------------------------------------------------------------------- /app/cameras/camera.ts: -------------------------------------------------------------------------------- 1 | // Note: The code in this file does not use the 'dst' output parameter of functions in the 2 | // 'wgpu-matrix' library, so produces many temporary vectors and matrices. 3 | // This is intentional, as this sample prefers readability over performance. 4 | import { Mat4, Vec3, Vec4, mat4, vec3 } from 'wgpu-matrix'; 5 | //import Input from './input'; 6 | 7 | // Information about this file, used by the sample UI 8 | // export const cameraSourceInfo = { 9 | // name: __filename.substring(__dirname.length + 1), 10 | // contents: __SOURCE__, 11 | // }; 12 | /* 13 | // Common interface for camera implementations 14 | export default interface Camera { 15 | // update updates the camera using the user-input and returns the view matrix. 16 | update(delta_time: number, input: Input): Mat4; 17 | 18 | // The camera matrix. 19 | // This is the inverse of the view matrix. 20 | matrix: Mat4; 21 | // Alias to column vector 0 of the camera matrix. 22 | right: Vec4; 23 | // Alias to column vector 1 of the camera matrix. 24 | up: Vec4; 25 | // Alias to column vector 2 of the camera matrix. 26 | back: Vec4; 27 | // Alias to column vector 3 of the camera matrix. 28 | position: Vec4; 29 | } 30 | */ 31 | // The common functionality between camera implementations 32 | class CameraBase { 33 | // The camera matrix 34 | /*private*/ matrix_ = new Float32Array([ 35 | 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 36 | ]); 37 | 38 | // The calculated view matrix 39 | /*private readonly*/ view_ = mat4.create(); 40 | 41 | // Aliases to column vectors of the matrix 42 | /*private */ right_ = new Float32Array(this.matrix_.buffer, 4 * 0, 4); 43 | /*private */ up_ = new Float32Array(this.matrix_.buffer, 4 * 4, 4); 44 | /*private */ back_ = new Float32Array(this.matrix_.buffer, 4 * 8, 4); 45 | /*private */ position_ = new Float32Array(this.matrix_.buffer, 4 * 12, 4); 46 | 47 | // Returns the camera matrix 48 | get matrix() { 49 | return this.matrix_; 50 | } 51 | // Assigns `mat` to the camera matrix 52 | set matrix(mat/*: Mat4*/) { 53 | mat4.copy(mat, this.matrix_); 54 | } 55 | 56 | // Returns the camera view matrix 57 | get view() { 58 | return this.view_; 59 | } 60 | // Assigns `mat` to the camera view 61 | set view(mat/*: Mat4*/) { 62 | mat4.copy(mat, this.view_); 63 | } 64 | 65 | // Returns column vector 0 of the camera matrix 66 | get right() { 67 | return this.right_; 68 | } 69 | // Assigns `vec` to the first 3 elements of column vector 0 of the camera matrix 70 | set right(vec/*: Vec3*/) { 71 | vec3.copy(vec, this.right_); 72 | } 73 | 74 | // Returns column vector 1 of the camera matrix 75 | get up() { 76 | return this.up_; 77 | } 78 | // Assigns `vec` to the first 3 elements of column vector 1 of the camera matrix 79 | set up(vec/*: Vec3*/) { 80 | vec3.copy(vec, this.up_); 81 | } 82 | 83 | // Returns column vector 2 of the camera matrix 84 | get back() { 85 | return this.back_; 86 | } 87 | // Assigns `vec` to the first 3 elements of column vector 2 of the camera matrix 88 | set back(vec/*: Vec3*/) { 89 | vec3.copy(vec, this.back_); 90 | } 91 | 92 | // Returns column vector 3 of the camera matrix 93 | get position() { 94 | return this.position_; 95 | } 96 | // Assigns `vec` to the first 3 elements of column vector 3 of the camera matrix 97 | set position(vec/*: Vec3*/) { 98 | vec3.copy(vec, this.position_); 99 | } 100 | } 101 | 102 | // WASDCamera is a camera implementation that behaves similar to first-person-shooter PC games. 103 | export class WASDCamera extends CameraBase/* implements Camera*/ { 104 | // The camera absolute pitch angle 105 | /*private*/ pitch = 0; 106 | // The camera absolute yaw angle 107 | /*private */yaw = 0; 108 | 109 | // The movement veloicty 110 | /*private readonly*/ velocity_ = vec3.create(); 111 | 112 | // Speed multiplier for camera movement 113 | movementSpeed = 10; 114 | 115 | // Speed multiplier for camera rotation 116 | rotationSpeed = 1; 117 | 118 | // Movement velocity drag coeffient [0 .. 1] 119 | // 0: Continues forever 120 | // 1: Instantly stops moving 121 | frictionCoefficient = 0.99; 122 | 123 | // Returns velocity vector 124 | get velocity() { 125 | return this.velocity_; 126 | } 127 | // Assigns `vec` to the velocity vector 128 | set velocity(vec/*: Vec3*/) { 129 | vec3.copy(vec, this.velocity_); 130 | } 131 | 132 | // Construtor 133 | constructor(options?: { 134 | // The initial position of the camera 135 | position?: Vec3; 136 | // The initial target of the camera 137 | target?: Vec3; 138 | }) { 139 | super(); 140 | if (options && (options.position || options.target)) { 141 | const position = options.position ?? vec3.create(0, 0, -5); 142 | const target = options.target ?? vec3.create(0, 0, 0); 143 | const forward = vec3.normalize(vec3.sub(target, position)); 144 | this.recalculateAngles(forward); 145 | this.position = position; 146 | } 147 | } 148 | 149 | // Returns the camera matrix 150 | get matrix() { 151 | return super.matrix; 152 | } 153 | 154 | // Assigns `mat` to the camera matrix, and recalcuates the camera angles 155 | set matrix(mat/*: Mat4*/) { 156 | super.matrix = mat; 157 | this.recalculateAngles(this.back); 158 | } 159 | 160 | update(deltaTime: number, input : any /*: Input*/ ) : Mat4 { 161 | const sign = (positive : boolean , negative : boolean ) => 162 | (positive ? 1 : 0) - (negative ? 1 : 0); 163 | 164 | // Apply the delta rotation to the pitch and yaw angles 165 | this.yaw -= input.analog.x * deltaTime * this.rotationSpeed; 166 | this.pitch -= input.analog.y * deltaTime * this.rotationSpeed; 167 | 168 | // Wrap yaw between [0° .. 360°], just to prevent large accumulation. 169 | this.yaw = mod(this.yaw, Math.PI * 2); 170 | // Clamp pitch between [-90° .. +90°] to prevent somersaults. 171 | this.pitch = clamp(this.pitch, -Math.PI / 2, Math.PI / 2); 172 | 173 | // Save the current position, as we're about to rebuild the camera matrix. 174 | const position = vec3.copy(this.position); 175 | 176 | // Reconstruct the camera's rotation, and store into the camera matrix. 177 | super.matrix = mat4.rotateX(mat4.rotationY(this.yaw), this.pitch); 178 | 179 | // Calculate the new target velocity 180 | const digital = input.digital; 181 | const deltaRight = sign(digital.right, digital.left); 182 | const deltaUp = sign(digital.up, digital.down); 183 | const targetVelocity = vec3.create(); 184 | const deltaBack = sign(digital.backward, digital.forward); 185 | vec3.addScaled(targetVelocity, this.right, deltaRight, targetVelocity); 186 | vec3.addScaled(targetVelocity, this.up, deltaUp, targetVelocity); 187 | vec3.addScaled(targetVelocity, this.back, deltaBack, targetVelocity); 188 | vec3.normalize(targetVelocity, targetVelocity); 189 | vec3.mulScalar(targetVelocity, this.movementSpeed, targetVelocity); 190 | 191 | // Mix new target velocity 192 | this.velocity = lerp( 193 | targetVelocity, 194 | this.velocity, 195 | Math.pow(1 - this.frictionCoefficient, deltaTime) 196 | ); 197 | 198 | // Integrate velocity to calculate new position 199 | this.position = vec3.addScaled(position, this.velocity, deltaTime); 200 | 201 | // Invert the camera matrix to build the view matrix 202 | this.view = mat4.invert(this.matrix); 203 | return this.view; 204 | } 205 | 206 | // Recalculates the yaw and pitch values from a directional vector 207 | recalculateAngles(dir : Vec3 ) { 208 | this.yaw = Math.atan2(dir[0], dir[2]); 209 | this.pitch = -Math.asin(dir[1]); 210 | } 211 | } 212 | 213 | // ArcballCamera implements a basic orbiting camera around the world origin 214 | export class ArcballCamera extends CameraBase/* implements Camera */{ 215 | // The camera distance from the target 216 | /* private */distance = 0; 217 | 218 | // The current angular velocity 219 | /* private */angularVelocity = 0; 220 | 221 | // The current rotation axis 222 | /* private */axis_ = vec3.create(); 223 | 224 | // Returns the rotation axis 225 | get axis() { 226 | return this.axis_; 227 | } 228 | // Assigns `vec` to the rotation axis 229 | set axis(vec/* : Vec3 */) { 230 | vec3.copy(vec, this.axis_); 231 | } 232 | 233 | // Speed multiplier for camera rotation 234 | rotationSpeed = 1; 235 | 236 | // Speed multiplier for camera zoom 237 | zoomSpeed = 0.1; 238 | 239 | // Rotation velocity drag coeffient [0 .. 1] 240 | // 0: Spins forever 241 | // 1: Instantly stops spinning 242 | frictionCoefficient = 0.999; 243 | 244 | // Construtor 245 | constructor(options ?: { 246 | // The initial position of the camera 247 | position?: Vec3; 248 | } ) { 249 | super(); 250 | if (options && options.position) { 251 | this.position = options.position; 252 | this.distance = vec3.len(this.position); 253 | this.back = vec3.normalize(this.position); 254 | this.recalcuateRight(); 255 | this.recalcuateUp(); 256 | } 257 | } 258 | 259 | // Returns the camera matrix 260 | get matrix() { 261 | return super.matrix; 262 | } 263 | 264 | // Assigns `mat` to the camera matrix, and recalcuates the distance 265 | set matrix(mat/* : Mat4 */) { 266 | super.matrix = mat; 267 | this.distance = vec3.len(this.position); 268 | } 269 | 270 | update(deltaTime : number, input: any/* : Input */)/* : Mat4 */{ 271 | const epsilon = 0.0000001; 272 | 273 | if (input.analog.touching) { 274 | // Currently being dragged. 275 | this.angularVelocity = 0; 276 | } else { 277 | // Dampen any existing angular velocity 278 | this.angularVelocity *= Math.pow(1 - this.frictionCoefficient, deltaTime); 279 | } 280 | 281 | // Calculate the movement vector 282 | const movement = vec3.create(); 283 | vec3.addScaled(movement, this.right, input.analog.x, movement); 284 | vec3.addScaled(movement, this.up, -input.analog.y, movement); 285 | 286 | // Cross the movement vector with the view direction to calculate the rotation axis x magnitude 287 | const crossProduct = vec3.cross(movement, this.back); 288 | 289 | // Calculate the magnitude of the drag 290 | const magnitude = vec3.len(crossProduct); 291 | 292 | if (magnitude > epsilon) { 293 | // Normalize the crossProduct to get the rotation axis 294 | this.axis = vec3.scale(crossProduct, 1 / magnitude); 295 | 296 | // Remember the current angular velocity. This is used when the touch is released for a fling. 297 | this.angularVelocity = magnitude * this.rotationSpeed; 298 | } 299 | 300 | // The rotation around this.axis to apply to the camera matrix this update 301 | const rotationAngle = this.angularVelocity * deltaTime; 302 | if (rotationAngle > epsilon) { 303 | // Rotate the matrix around axis 304 | // Note: The rotation is not done as a matrix-matrix multiply as the repeated multiplications 305 | // will quickly introduce substantial error into the matrix. 306 | this.back = vec3.normalize(rotate(this.back, this.axis, rotationAngle)); 307 | this.recalcuateRight(); 308 | this.recalcuateUp(); 309 | } 310 | 311 | // recalculate `this.position` from `this.back` considering zoom 312 | if (input.analog.zoom !== 0) { 313 | this.distance *= 1 + input.analog.zoom * this.zoomSpeed; 314 | } 315 | this.position = vec3.scale(this.back, this.distance); 316 | 317 | // Invert the camera matrix to build the view matrix 318 | this.view = mat4.invert(this.matrix); 319 | return this.view; 320 | } 321 | 322 | // Assigns `this.right` with the cross product of `this.up` and `this.back` 323 | recalcuateRight() { 324 | this.right = vec3.normalize(vec3.cross(this.up, this.back)); 325 | } 326 | 327 | // Assigns `this.up` with the cross product of `this.back` and `this.right` 328 | recalcuateUp() { 329 | this.up = vec3.normalize(vec3.cross(this.back, this.right)); 330 | } 331 | } 332 | 333 | // Returns `x` clamped between [`min` .. `max`] 334 | function clamp(x : number , min : number , max : number )/* : number */ { 335 | return Math.min(Math.max(x, min), max); 336 | } 337 | 338 | // Returns `x` float-modulo `div` 339 | function mod(x : number , div: number )/* : number */ { 340 | return x - Math.floor(Math.abs(x) / div) * div * Math.sign(x); 341 | } 342 | 343 | // Returns `vec` rotated `angle` radians around `axis` 344 | function rotate(vec : Vec3 , axis : Vec3 , angle : number )/* : Vec3 */ { 345 | return vec3.transformMat4Upper3x3(vec, mat4.rotation(axis, angle)); 346 | } 347 | 348 | // Returns the linear interpolation between 'a' and 'b' using 's' 349 | function lerp(a : Vec3 , b : Vec3 , s : number )/* : Vec3 */ { 350 | return vec3.addScaled(a, vec3.sub(b, a), s); 351 | } 352 | --------------------------------------------------------------------------------