diff --git a/.github/PR_PREVIEW.md b/.github/PR_PREVIEW.md
new file mode 100644
index 0000000..1e991e1
--- /dev/null
+++ b/.github/PR_PREVIEW.md
@@ -0,0 +1,82 @@
+# PR Preview Deployment
+
+This repository has automated PR preview deployments set up via GitHub Actions.
+
+## How It Works
+
+When a Pull Request is created or updated:
+
+1. The PR Preview workflow automatically deploys the PR branch to GitHub Pages
+2. Each PR gets its own subdirectory: `https://ap0ught.github.io/matrix/pr-{number}/`
+3. A comment is posted on the PR with links to test the preview
+4. The preview is automatically updated when new commits are pushed
+
+## Manual Deployment
+
+You can also manually trigger a preview deployment:
+
+1. Go to the Actions tab
+2. Select "PR Preview Deployment"
+3. Click "Run workflow"
+4. Select the branch you want to deploy
+
+## Preview URLs
+
+After deployment, you can access your PR preview at:
+
+- **Main preview:** `https://ap0ught.github.io/matrix/pr-{number}/`
+- **With options:** `https://ap0ught.github.io/matrix/pr-{number}/?suppressWarnings=true`
+
+### Test Links
+
+The automated PR comment includes convenient test links for different Matrix versions:
+
+- Default Matrix effect
+- Mirror mode with mouse interaction
+- 3D volumetric mode
+- Resurrections version
+
+## Cleanup
+
+PR previews remain on GitHub Pages until manually removed. To clean up old previews:
+
+1. Check out the `gh-pages` branch
+2. Remove the `pr-{number}` directory
+3. Commit and push
+
+## Requirements
+
+- GitHub Pages must be enabled for the repository
+- The workflow requires these permissions:
+ - `contents: write` - to push to gh-pages branch
+ - `pages: write` - to deploy to GitHub Pages
+ - `pull-requests: write` - to comment on PRs
+
+## Technical Details
+
+- **Workflow file:** `.github/workflows/pr-preview.yml`
+- **Deployment branch:** `gh-pages`
+- **Directory structure:** Each PR gets its own subdirectory
+- **Files deployed:** `index.html`, `js/`, `lib/`, `assets/`, `shaders/`
+
+## Limitations
+
+- This is a static site deployment - no server-side processing
+- Previews don't require any build step (keeping with the project's philosophy)
+- Each preview is a complete copy of the web application
+
+## Local Testing Alternative
+
+If you prefer to test locally instead of using the preview:
+
+```bash
+# Clone and checkout the PR branch
+git fetch origin pull/{PR_NUMBER}/head:pr-{PR_NUMBER}
+git checkout pr-{PR_NUMBER}
+
+# Start a local server
+python3 -m http.server 8000
+
+# Open in browser
+open http://localhost:8000/?suppressWarnings=true
+```
diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml
new file mode 100644
index 0000000..9905163
--- /dev/null
+++ b/.github/workflows/pr-preview.yml
@@ -0,0 +1,277 @@
+name: PR Preview Deployment
+
+# Deploy PR branches to GitHub Pages at /pr-{number}/ for testing
+# This allows testing changes without merging to master
+
+on:
+ workflow_dispatch: # Allow manual trigger
+ pull_request:
+ types: [opened, synchronize, reopened]
+ branches:
+ - master
+
+permissions:
+ contents: write
+ pages: write
+ id-token: write
+ pull-requests: write
+
+jobs:
+ deploy-preview:
+ name: Deploy PR Preview
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout PR branch
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ fetch-depth: 1
+ token: ${{ github.token }}
+
+ - name: Determine preview path
+ id: preview
+ run: |
+ if [ "${{ github.event_name }}" = "pull_request" ]; then
+ PR_NUMBER="${{ github.event.pull_request.number }}"
+ PREVIEW_PATH="pr-${PR_NUMBER}"
+ else
+ # Manual trigger - use branch name
+ BRANCH_NAME="${GITHUB_REF#refs/heads/}"
+ PREVIEW_PATH="pr-${BRANCH_NAME//\//-}"
+ fi
+ echo "path=${PREVIEW_PATH}" >> $GITHUB_OUTPUT
+ echo "url=https://ap0ught.github.io/matrix/${PREVIEW_PATH}/" >> $GITHUB_OUTPUT
+ echo "Preview will be deployed to: ${PREVIEW_PATH}"
+
+ - name: Checkout gh-pages branch
+ uses: actions/checkout@v4
+ with:
+ ref: gh-pages
+ path: gh-pages
+ fetch-depth: 0
+ token: ${{ github.token }}
+ continue-on-error: true
+
+ - name: Initialize gh-pages if needed
+ run: |
+ if [ ! -d "gh-pages/.git" ]; then
+ echo "Creating new gh-pages branch"
+ rm -rf gh-pages
+ mkdir -p gh-pages
+ cd gh-pages
+ git init
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git checkout -b gh-pages
+
+ # Set up remote with authentication
+ git remote add origin "https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.git"
+
+ echo "# Matrix PR Previews" > README.md
+ echo "" >> README.md
+ echo "This branch contains PR preview deployments." >> README.md
+ echo "Main site: https://ap0ught.github.io/matrix/" >> README.md
+
+ git add .
+ git commit -m "Initialize gh-pages branch"
+ cd ..
+ else
+ echo "gh-pages branch exists, configuring..."
+ cd gh-pages
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ cd ..
+ fi
+
+ - name: Prepare preview directory
+ run: |
+ PREVIEW_PATH="${{ steps.preview.outputs.path }}"
+
+ # Create preview directory in gh-pages
+ mkdir -p "gh-pages/${PREVIEW_PATH}"
+
+ # Copy web application files to preview directory
+ cp index.html "gh-pages/${PREVIEW_PATH}/"
+ cp -r js "gh-pages/${PREVIEW_PATH}/"
+ cp -r lib "gh-pages/${PREVIEW_PATH}/"
+ cp -r assets "gh-pages/${PREVIEW_PATH}/"
+ cp -r shaders "gh-pages/${PREVIEW_PATH}/"
+
+ # Optional files (don't fail if missing)
+ cp README.md "gh-pages/${PREVIEW_PATH}/" 2>/dev/null || true
+ cp screenshot.png "gh-pages/${PREVIEW_PATH}/" 2>/dev/null || true
+
+ echo "✅ Preview files copied to gh-pages/${PREVIEW_PATH}"
+ ls -la "gh-pages/${PREVIEW_PATH}"
+
+ - name: Update gh-pages index
+ run: |
+ cd gh-pages
+
+ # Create or update index.html with list of previews
+ cat > index.html <<'HTMLEOF'
+
+
+
+
+
+ Matrix PR Previews
+
+
+
+ Matrix PR Preview Deployments
+
+
+
+ Available PR Previews:
+
+
+
+
+
+ HTMLEOF
+
+ echo "✅ Updated gh-pages index.html"
+
+ - name: Deploy to gh-pages
+ run: |
+ cd gh-pages
+
+ # Ensure git config is set
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+
+ # Set up authentication for push
+ git remote set-url origin "https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.git" || \
+ git remote add origin "https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.git"
+
+ # Stage all changes
+ git add .
+
+ # Check if there are changes to commit
+ if git diff --staged --quiet; then
+ echo "No changes to deploy"
+ else
+ git commit -m "Deploy preview: ${{ steps.preview.outputs.path }}"
+
+ # Push to gh-pages branch
+ git push origin gh-pages --force
+ echo "✅ Successfully deployed to gh-pages"
+ fi
+
+ - name: Comment on PR with preview URL
+ if: github.event_name == 'pull_request'
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const previewUrl = '${{ steps.preview.outputs.url }}';
+ const comment = `## 🎬 PR Preview Deployed!
+
+ Your changes are now available for testing:
+
+ **Preview URL:** ${previewUrl}
+
+ ### Test Links:
+ - [Default Matrix](${previewUrl}?suppressWarnings=true)
+ - [Mirror Effect](${previewUrl}?effect=mirror&suppressWarnings=true)
+ - [3D Mode](${previewUrl}?version=3d&suppressWarnings=true)
+ - [Resurrections](${previewUrl}?version=resurrections&suppressWarnings=true)
+
+ The preview will be updated automatically when you push new commits.
+
+ ---
+ *Preview deployed from commit ${{ github.event.pull_request.head.sha }}*`;
+
+ // Find existing preview comment
+ const { data: comments } = await github.rest.issues.listComments({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ });
+
+ const botComment = comments.find(comment =>
+ comment.user.type === 'Bot' &&
+ comment.body.includes('PR Preview Deployed')
+ );
+
+ if (botComment) {
+ // Update existing comment
+ await github.rest.issues.updateComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: botComment.id,
+ body: comment
+ });
+ } else {
+ // Create new comment
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ body: comment
+ });
+ }
+
+ - name: Summary
+ run: |
+ echo "## 🎉 PR Preview Deployed Successfully!" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "**Preview URL:** ${{ steps.preview.outputs.url }}" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "### Quick Test Links:" >> $GITHUB_STEP_SUMMARY
+ echo "- [Default Matrix](${{ steps.preview.outputs.url }}?suppressWarnings=true)" >> $GITHUB_STEP_SUMMARY
+ echo "- [Mirror Effect](${{ steps.preview.outputs.url }}?effect=mirror&suppressWarnings=true)" >> $GITHUB_STEP_SUMMARY
+ echo "- [3D Mode](${{ steps.preview.outputs.url }}?version=3d&suppressWarnings=true)" >> $GITHUB_STEP_SUMMARY
diff --git a/js/regl/main.js b/js/regl/main.js
index d7b1a74..e4d00ae 100644
--- a/js/regl/main.js
+++ b/js/regl/main.js
@@ -73,7 +73,7 @@ export default async (canvas, config) => {
};
const effects = createEffectsMapping("regl", passModules);
const effectPass = getEffectPass(config.effect, effects, "palette");
- const context = { regl, config, lkg, cameraTex, cameraAspectRatio };
+ const context = { regl, canvas, config, lkg, cameraTex, cameraAspectRatio };
const pipeline = makePipeline(context, [makeRain, makeBloomPass, effectPass, makeQuiltPass]);
const screenUniforms = { tex: pipeline[pipeline.length - 1].outputs.primary };
const drawToScreen = regl({ uniforms: screenUniforms });
diff --git a/js/regl/mirrorPass.js b/js/regl/mirrorPass.js
index 3d26fea..0d6d973 100644
--- a/js/regl/mirrorPass.js
+++ b/js/regl/mirrorPass.js
@@ -1,19 +1,22 @@
import { loadText, makePassFBO, makePass } from "./utils.js";
-let start;
-const numClicks = 5;
-const clicks = Array(numClicks).fill([0, 0, -Infinity]).flat();
-let aspectRatio = 1;
+export default ({ regl, canvas, config, cameraTex, cameraAspectRatio }, inputs) => {
+ let start;
+ const numClicks = 5;
+ const clicks = Array(numClicks)
+ .fill()
+ .map((_) => [0, 0, -Infinity]);
+ let aspectRatio = 1;
-let index = 0;
-window.onclick = (e) => {
- clicks[index * 3 + 0] = 0 + e.clientX / e.srcElement.clientWidth;
- clicks[index * 3 + 1] = 1 - e.clientY / e.srcElement.clientHeight;
- clicks[index * 3 + 2] = (Date.now() - start) / 1000;
- index = (index + 1) % numClicks;
-};
+ let index = 0;
+ canvas.onmousedown = (e) => {
+ const rect = e.srcElement.getBoundingClientRect();
+ clicks[index][0] = 0 + (e.clientX - rect.x) / rect.width;
+ clicks[index][1] = 1 - (e.clientY - rect.y) / rect.height;
+ clicks[index][2] = (performance.now() - start) / 1000;
+ index = (index + 1) % numClicks;
+ };
-export default ({ regl, config, cameraTex, cameraAspectRatio }, inputs) => {
const output = makePassFBO(regl, config.useHalfFloat);
const mirrorPassFrag = loadText("shaders/glsl/mirrorPass.frag.glsl");
const render = regl({
@@ -23,14 +26,19 @@ export default ({ regl, config, cameraTex, cameraAspectRatio }, inputs) => {
tex: inputs.primary,
bloomTex: inputs.bloom,
cameraTex,
- clicks: () => clicks,
+ // REGL bug can misinterpret array uniforms
+ ["clicks[0]"]: () => clicks[0],
+ ["clicks[1]"]: () => clicks[1],
+ ["clicks[2]"]: () => clicks[2],
+ ["clicks[3]"]: () => clicks[3],
+ ["clicks[4]"]: () => clicks[4],
aspectRatio: () => aspectRatio,
cameraAspectRatio,
},
framebuffer: output,
});
- start = Date.now();
+ start = performance.now();
return makePass(
{
diff --git a/js/webgpu/bloomPass.js b/js/webgpu/bloomPass.js
index 88e5c97..2bb2b3c 100644
--- a/js/webgpu/bloomPass.js
+++ b/js/webgpu/bloomPass.js
@@ -20,7 +20,7 @@ const makePyramid = (device, size, pyramidHeight) =>
.map((_, index) =>
makeComputeTarget(
device,
- size.map((x) => Math.floor(x * 2 ** -index)),
+ size.map((x) => Math.max(1, Math.floor(x * 2 ** -index))),
),
);
@@ -102,7 +102,7 @@ export default ({ config, device }) => {
const build = (screenSize, inputs) => {
// Since the bloom is blurry, we downscale everything
- scaledScreenSize = screenSize.map((x) => Math.floor(x * bloomSize));
+ scaledScreenSize = screenSize.map((x) => Math.max(1, Math.floor(x * bloomSize)));
destroyPyramid(hBlurPyramid);
hBlurPyramid = makePyramid(device, scaledScreenSize, pyramidHeight);
@@ -144,7 +144,11 @@ export default ({ config, device }) => {
computePass.setPipeline(blurPipeline);
for (let i = 0; i < pyramidHeight; i++) {
- const dispatchSize = [Math.ceil(Math.floor(scaledScreenSize[0] * 2 ** -i) / 32), Math.floor(Math.floor(scaledScreenSize[1] * 2 ** -i)), 1];
+ const dispatchSize = [
+ Math.max(1, Math.ceil(Math.floor(scaledScreenSize[0] * 2 ** -i) / 32)),
+ Math.max(1, Math.floor(Math.floor(scaledScreenSize[1] * 2 ** -i))),
+ 1,
+ ];
computePass.setBindGroup(0, hBlurBindGroups[i]);
computePass.dispatchWorkgroups(...dispatchSize);
computePass.setBindGroup(0, vBlurBindGroups[i]);
diff --git a/js/webgpu/main.js b/js/webgpu/main.js
index 5a7fe7b..9d30d8f 100644
--- a/js/webgpu/main.js
+++ b/js/webgpu/main.js
@@ -59,6 +59,7 @@ export default async (canvas, config) => {
config,
adapter,
device,
+ canvas,
canvasContext,
timeBuffer,
canvasFormat,
diff --git a/js/webgpu/mirrorPass.js b/js/webgpu/mirrorPass.js
index 9de12af..2da85a5 100644
--- a/js/webgpu/mirrorPass.js
+++ b/js/webgpu/mirrorPass.js
@@ -1,24 +1,7 @@
import { structs } from "../../lib/gpu-buffer.js";
import { makeComputeTarget, makeUniformBuffer, loadShader, makeBindGroup, makePass } from "./utils.js";
-let start;
-const numTouches = 5;
-const touches = Array(numTouches)
- .fill()
- .map((_) => [0, 0, -Infinity, 0]);
-let aspectRatio = 1;
-
-let index = 0;
-let touchesChanged = true;
-window.onclick = (e) => {
- touches[index][0] = 0 + e.clientX / e.srcElement.clientWidth;
- touches[index][1] = 1 - e.clientY / e.srcElement.clientHeight;
- touches[index][2] = (Date.now() - start) / 1000;
- index = (index + 1) % numTouches;
- touchesChanged = true;
-};
-
-export default ({ config, device, cameraTex, cameraAspectRatio, timeBuffer }) => {
+export default ({ config, device, canvas, cameraTex, cameraAspectRatio, timeBuffer }) => {
const assets = [loadShader(device, "shaders/wgsl/mirrorPass.wgsl")];
const linearSampler = device.createSampler({
@@ -26,6 +9,24 @@ export default ({ config, device, cameraTex, cameraAspectRatio, timeBuffer }) =>
minFilter: "linear",
});
+ let start;
+ const numTouches = 5;
+ const touches = Array(numTouches)
+ .fill()
+ .map((_) => [0, 0, -Infinity, 0]);
+ let aspectRatio = 1;
+
+ let index = 0;
+ let touchesChanged = true;
+ canvas.onmousedown = (e) => {
+ const rect = e.srcElement.getBoundingClientRect();
+ touches[index][0] = 0 + (e.clientX - rect.x) / rect.width;
+ touches[index][1] = 1 - (e.clientY - rect.y) / rect.height;
+ touches[index][2] = (performance.now() - start) / 1000;
+ index = (index + 1) % numTouches;
+ touchesChanged = true;
+ };
+
let computePipeline;
let configBuffer;
let sceneUniforms;
@@ -99,7 +100,7 @@ export default ({ config, device, cameraTex, cameraAspectRatio, timeBuffer }) =>
computePass.end();
};
- start = Date.now();
+ start = performance.now();
return makePass("Mirror", loaded, build, run);
};