Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ build_*/
out/
out_*/
__pycache__/
node_modules/
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "vendor/cuda-webshader"]
path = vendor/cuda-webshader
url = https://github.com/SamG-Coder/cuda-webshader.git
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@

# NNTC: Non-Neural Texture Compression

> **CUDA WebShader browser encoder fork.** This fork adds browser encoding and
> direct handoff to the existing WebGPU viewer. Clone with `--recurse-submodules`,
> run `npm ci` then `npm start`, and open `http://127.0.0.1:5174`.
> See [the browser encoder guide](docs/BROWSER_ENCODER.md) for supported features,
> tests and differences from the native encoder. The browser implementation uses
> the same NNTC asset format with a different optimizer; it does not reproduce
> the native encoder's full option set or exact output. The original project
> documentation follows below.

NNTC is a generalized latent/modulation codec for PBR material texture sets, in the same broad family of ideas as PVRTC1, but with more latent dimensions and a fixed bilinear feature expansion followed by a fitted affine decoder. It builds directly off the author's [public texture work from 2012](https://web.archive.org/web/20201024153426/https://sites.google.com/site/richgel99/luma_chroma_texture_compression). It uses a bilinear/degree-2 polynomial reconstruction: a degree-2 polynomial whose only quadratic terms are products between the two latents, which are stored as standard BC4/BC5 or uncompressed mipmapped textures. The full-resolution latent texture(s) carry selector/detail information, while the quarter-resolution latent texture carries more slowly varying material/color state. Decoding consists of a fixed, PVRTC1-like bilinear modulation/feature expansion followed by a single fitted affine transform, implemented as a GEMV plus bias. The fitted decoder itself contains no nonlinearity at all; there are no fitted nonlinear functions, activation functions, hidden layers, or neural-network layers.

The `nntc_encode` tool (for Windows or Linux) compresses a PBR material texture set of up to six 24-bpp textures into **two mipmapped latent planes and (typically) a few dozen
Expand Down
147 changes: 147 additions & 0 deletions docs/BROWSER_ENCODER.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# Browser encoder using CUDA WebShader

This fork adds a working browser encoder at `webgpu/encode.html`. It compiles
the CUDA device kernels in `webgpu/encoder/kernels.cu` with CUDA WebShader and
executes them on WebGPU. The existing WebGPU viewer can open the exported files
or receive them directly from the encoder's **Open interactive viewer** button.
No CUDA installation or NVIDIA GPU is required by this path.

## Run

```sh
git clone --recurse-submodules --branch feat/cuda-webshader-encoder https://github.com/SamG-Coder/nntc.git
cd nntc
npm ci
npm start
```

Open http://127.0.0.1:5174. For an existing checkout, first run
`git submodule update --init --recursive`. Node 20+ is needed for the local
server and tests. The actual application is static JavaScript and needs only
HTTPS or localhost and a working WebGPU browser. A static deployment must serve
both `webgpu/` and `vendor/cuda-webshader/src/` with JavaScript MIME types; do not
serve only the `webgpu/` directory with the old viewer-only server.

Choose one to six same-sized images, or try the bundled material. The browser
reads RGB bytes, ignores alpha with a notice, and pads the right/bottom edges to
multiples of four with clamped source pixels. Choose the latent channel counts,
number of fitting passes, mipmaps, and BC or uncompressed storage. Download the
ZIP and extract the descriptor and DDS files together. The output descriptor is
the existing `nntc-dds-1` format, with an informational `encoder` field.

The app does not upload source images. Cancellation takes effect between GPU
dispatches and host stages. Images beyond the adapter's buffer/dispatch limits
or the encoder's estimated 1 GiB working-memory ceiling are refused before GPU
allocation. The UI also bounds source image memory before reading pixels.

## Implemented path

* One to six RGB maps; C0 and C1 each support one to four latent channels.
* Shared PCA initialization and an area-averaged source mip chain.
* The same ordered bilinear feature vector `[c; s; s*c]` and affine decoder.
* Center-only fitting or the center plus NNTC's four rotated-grid fractional
positions. Both latents and the source use bilinear clamp sampling.
* Streaming GPU normal equations; the at-most-25-variable dense solve runs in
JavaScript's double precision. The GPU never holds all observations at once.
* Four-color coordinate descent on each latent plane. Each texel gathers its
affected filter sites and solves its channel vector's small quadratic.
* BC4/BC5 endpoint/selector fitting (split into two DDS files for C0=3 or 4), or
R8/RG8/RGBA8 storage, plus quantized R8/RG8/RGBA8 for the quarter-size plane.
* A final decoder fit using the actual quantized/BC-decoded values that are
exported. Ranges and decoder coefficients are shared by all stored mips.
* Existing viewer validation of every exported descriptor and DDS file, source
comparison, base-level PSNR, ZIP download and interactive viewer handoff.

The GPU performs observation generation, normal-equation accumulation, latent
updates and reconstruction. JavaScript performs image intake, source mip/PCA
initialization, small dense decoder solves, quantization/BC packing and file I/O.
The `cuda-webshader` submodule is pinned to the commit that supplies the new
`NormalEquations` streaming operation.

## Differences from the native encoder

This is a **format-compatible browser implementation, not native encoder parity**.
The native C++/CUDA encoder remains available and unchanged. The browser path
does not implement its full command-line/material-JSON option set or reproduce
its exact initialization, global sparse latent solves, ridge acceptance ladder,
palette modes, per-map weighting, sRGB/normal-specific mip filters, or joint
decoder-objective BC block refinement. BC packing here minimizes latent-channel
error, then refits the decoder. Raising fitting passes does not add those native
algorithms.

GPU normal-equation products and sums use f32, with a fixed reduction order;
only the small host solve uses f64. Results are not promised to match native
CUDA bytes, PSNR or performance, or to be bit-identical across GPU vendors.
The four-color solver uses a bounded, ridge-regularized update, rather than the
native global sparse solve. Quality must be measured for the intended materials.

The source mip filter is a box/area filter on the supplied RGB bytes; it does not
renormalize normals or linearize sRGB. The reported PSNR compares unclamped
reconstruction with original RGB at base-level pixel centers, excluding padding.
It uses the BC specification's palette values; actual hardware BC interpolation
and the viewer's byte-unpack fallback can vary slightly. It is not a fractional
site or whole-mip-chain error measurement.

## Verification

```sh
npm test
npm run test:gpu
npm run test:ui
# Optional independent upstream decoder (requires numpy and Pillow):
python tests/check-web-exports.py
```

The Node tests compile every CUDA entry and check all 32 combinations of latent
layout and storage type through the existing viewer parser, partial BC blocks,
DDS mip sizes, malformed input rejection and ZIP CRCs. GPU tests exercise
one/six outputs, split BC planes, uncompressed RGB, odd sizes, mip chains,
constant input, repeatability, cancellation and buffer cleanup. An independent
JavaScript decoder reads the generated DDS bytes and descriptor, and checks its
reconstruction against the GPU. The UI test encodes the bundled single-map and
four-map materials, downloads ZIPs, loads the interactive viewer, and checks a
mobile viewport and browser errors.

The Python cross-check also validates ZIP CRCs and runs the original repository's
strict DDS reader and decoder on both downloaded assets. It reports saturated,
rounded RGB8 PSNR, which can differ from the browser's unclamped float PSNR.

On Windows the browser tests use installed Edge. Set `NNTC_BROWSER` to another
Playwright browser channel. On other platforms they use Playwright Chromium;
install it with `npx playwright install chromium`. `NNTC_SOFTWARE_GPU=1` requests
SwiftShader for the small numerical GPU suite. Reports, downloaded assets and
screenshots go to ignored `out/web-tests/`.

Measured on the development machine with Edge/WebGPU: the bundled 512×512
`m1.png`, default C0=2/C1=4, six fitting passes, fractional sites and mipmaps,
encoded in approximately 2.8 seconds at 35.2 dB, producing approximately
431.2 KiB including its JSON. This is a local smoke measurement, not a native
CUDA comparison or a claim for other hardware.

The four-map `m1.png` through `m4.png` material took approximately 3.5 seconds
with the same options, producing 436.0 KiB and per-map PSNR of 25.9, 33.9, 30.7
and 34.9 dB. The optional SwiftShader test could not obtain a WebGPU adapter in
the installed Edge browser; it is not counted as a passing software-adapter test.

## JavaScript API

```js
import {BrowserEncoder} from './webgpu/encoder/encoder.js';

const encoder = await BrowserEncoder.create();
try {
const result = await encoder.encode([
{ name: 'albedo.png', width, height, data: rgbaUint8Array }
], { name: 'material', c0: 2, c1: 4, iterations: 6,
mips: true, bc: true, sites: 5,
signal: abortController.signal, onProgress: console.log });
// result.files: Map<string, Uint8Array>, containing DDS and JSON files.
// result.psnr: per-source-map PSNR; result.reconstructed: interleaved floats.
} finally {
await encoder.dispose();
}
```

An existing CUDA WebShader runtime can be passed to `BrowserEncoder.create({runtime})`;
in that case the encoder does not own or dispose that runtime. One encoder accepts
one encode at a time; cancellation or failure releases all per-encode GPU buffers.
63 changes: 63 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "nntc-cuda-webshader",
"private": true,
"type": "module",
"scripts": {
"start": "node scripts/serve-web.mjs",
"test": "node --test tests/web-encoder.test.mjs",
"test:gpu": "node tests/web-encoder-gpu.mjs",
"test:ui": "node tests/web-encoder-ui.mjs"
},
"devDependencies": {"playwright": "1.56.1"},
"engines": {"node": ">=20"}
}
23 changes: 23 additions & 0 deletions scripts/serve-web.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import http from 'node:http';
import {readFile,stat} from 'node:fs/promises';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
const root=fileURLToPath(new URL('../',import.meta.url));
const mime={'.html':'text/html','.js':'text/javascript','.mjs':'text/javascript','.json':'application/json','.css':'text/css','.cu':'text/plain','.wgsl':'text/plain','.png':'image/png','.jpg':'image/jpeg','.dds':'application/octet-stream'};
export function createServer() {
return http.createServer(async(req,res)=>{
try {
const pathname=decodeURIComponent(new URL(req.url,'http://localhost').pathname);
if(pathname==='/') {res.writeHead(302,{Location:'/webgpu/encode.html'}).end();return;}
const relative=pathname==='/'?'webgpu/encode.html':pathname.replace(/^\/+/,''),file=path.resolve(root,relative);
if(!file.startsWith(root)||relative.split(/[\\/]/).some(p=>p.startsWith('.'))) {res.writeHead(403).end();return;}
const target=(await stat(file)).isDirectory()?path.join(file,'index.html'):file;
const bytes=await readFile(target);
res.writeHead(200,{'Content-Type':mime[path.extname(target)]||'application/octet-stream','Cache-Control':'no-store'}).end(bytes);
} catch {res.writeHead(404).end('Not found');}
});
}
if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url)) {
const port=Number(process.env.PORT||5174);
createServer().listen(port,'127.0.0.1',()=>console.log(`NNTC encoder: http://127.0.0.1:${port}/`));
}
26 changes: 26 additions & 0 deletions tests/check-web-exports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Verify browser ZIP exports with upstream's independent Python decoder.

Run npm run test:ui first, then python tests/check-web-exports.py.
Requires numpy and Pillow (the existing decoder's dependencies).
"""
import pathlib
import sys
import tempfile
import zipfile

ROOT = pathlib.Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'tools'))
import dds_decode

for archive, sources in [('m1.zip', ['m1.png']), ('m1-m4.zip', ['m1.png', 'm2.png', 'm3.png', 'm4.png'])]:
with tempfile.TemporaryDirectory() as temporary:
directory = pathlib.Path(temporary)
with zipfile.ZipFile(ROOT / 'out' / 'web-tests' / archive) as bundle:
assert bundle.testzip() is None, 'ZIP CRC failure'
for name in bundle.namelist():
assert pathlib.PurePosixPath(name).name == name and '\\' not in name, 'Expected flat ZIP entries'
(directory / name).write_bytes(bundle.read(name))
descriptor = directory / 'm1_nntc.json'
# Exercise upstream's header, format and every mip decoder, not just our JS reader.
sys.argv = ['dds_decode.py', str(descriptor), '--psnr'] + [str(ROOT / 'examples' / name) for name in sources]
assert dds_decode.main() == 0
59 changes: 59 additions & 0 deletions tests/web-encoder-gpu.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<!doctype html><meta charset="utf-8"><link rel="icon" href="data:,"><title>NNTC GPU tests</title>
<script type="module">
import {BrowserEncoder} from '../webgpu/encoder/encoder.js';
import {ddsRead} from '../webgpu/dds.js';

// Independent DDS + descriptor reconstruction, including non-rounded BC palettes.
function decodeReference(result) {
const {descriptor:d}=result,planes=d.textures.map(t=>{
const out=new Float64Array(t.width*t.height*t.channels_used);let base=0;
for(const entry of t.files||[t]) {
const img=ddsRead(entry.file,result.files.get(entry.file)),channels=img.channels,offset=img.levels[0].offset;
for(let y=0;y<img.H;y++)for(let x=0;x<img.W;x++)for(let c=0;c<channels&&base+c<t.channels_used;c++) {
let value;
if(!img.bc)value=img.bytes[offset+(y*img.W+x)*channels+c]/255;
else {
const block=offset+((y>>2)*Math.ceil(img.W/4)+(x>>2))*channels*8+c*8,a=img.bytes[block],b=img.bytes[block+1];
const palette=a>b?[a,b,...Array.from({length:6},(_,i)=>((6-i)*a+(i+1)*b)/7)]:[a,b,...Array.from({length:4},(_,i)=>((4-i)*a+(i+1)*b)/5),0,255];
const bit=((y%4)*4+x%4)*3,byte=block+2+(bit>>3),selector=((img.bytes[byte]|((img.bytes[byte+1]||0)<<8))>>(bit%8))&7;
value=palette[selector]/255;
}
out[(y*img.W+x)*t.channels_used+base+c]=t.dequantise.lo[base+c]+value*(t.dequantise.hi[base+c]-t.dequantise.lo[base+c]);
}base+=channels;
}return out;
});
const t0=d.textures[0],t1=d.textures[1],layer=d.decoder.layers[0],output=new Float64Array(result.reconstructed.length);
const sample=(c,x,y)=>{const ix=Math.floor(x),iy=Math.floor(y),fx=x-ix,fy=y-iy;let value=0;for(let dy=0;dy<2;dy++)for(let dx=0;dx<2;dx++)value+=planes[1][(Math.max(0,Math.min(t1.height-1,iy+dy))*t1.width+Math.max(0,Math.min(t1.width-1,ix+dx)))*t1.channels_used+c]*(dx?fx:1-fx)*(dy?fy:1-fy);return value;};
for(let y=0;y<t0.height;y++)for(let x=0;x<t0.width;x++) {
const s=Array.from(planes[0].slice((y*t0.width+x)*t0.channels_used,(y*t0.width+x+1)*t0.channels_used));
const c=Array.from({length:t1.channels_used},(_,i)=>sample(i,(x+.5)*t1.width/t0.width-.5,(y+.5)*t1.height/t0.height-.5));
const phi=[...c,...s,...s.flatMap(a=>c.map(b=>a*b))];
for(let o=0;o<d.decoder.nout;o++)output[(y*t0.width+x)*d.decoder.nout+o]=layer.bias[o]+phi.reduce((sum,v,i)=>sum+v*layer.weights[o*phi.length+i],0);
}
let difference=0;for(let i=0;i<output.length;i++)difference=Math.max(difference,Math.abs(output[i]-result.reconstructed[i]));
if(difference>2e-4)throw Error(`Exported DDS decode differs from GPU reconstruction: ${difference}`);
return difference;
}
window.runTest=async(options={})=>{
const width=options.width||8,height=options.height||8;
const data=new Uint8ClampedArray(width*height*4);
for(let y=0;y<height;y++)for(let x=0;x<width;x++)data.set([Math.round(x/Math.max(1,width-1)*255),Math.round(y/Math.max(1,height-1)*255),Math.round((x+y)/Math.max(1,width+height-2)*255),255],(y*width+x)*4);
const encoder=await BrowserEncoder.create();
const errors=[];encoder.runtime.device.addEventListener('uncapturederror',event=>errors.push(event.error.message));
try {
const images=Array.from({length:options.textures||1},(_,i)=>({name:`ramp${i}.png`,width,height,data:options.constant?new Uint8ClampedArray(width*height*4).fill(128):Uint8ClampedArray.from(data,(v,k)=>k%4===3?255:i%2?255-v:v*(1-i/12))}));
const result=await encoder.encode(images,{iterations:1,...options});
const difference=decodeReference(result);
const digest=async files=>Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',await new Blob([...files.values()]).arrayBuffer()))).map(x=>x.toString(16).padStart(2,'0')).join('');
const hash=await digest(result.files);
if(options.repeat) {const repeated=await encoder.encode(images,{iterations:1,...options});if(await digest(repeated.files)!==hash)throw Error('Repeated encode differs.');}
const signal=new AbortController();signal.abort();
try{await encoder.encode(images,{signal:signal.signal});throw Error('Cancelled encode succeeded');}catch(error){if(error.name!=='AbortError')throw error;}
const midRun=new AbortController();
try{await encoder.encode(images,{iterations:1,signal:midRun.signal,onProgress:message=>{if(message.startsWith('Optimizing'))midRun.abort();}});throw Error('Mid-run cancellation succeeded');}catch(error){if(error.name!=='AbortError')throw error;}
if(encoder.runtime.buffers.size!==0)throw Error('Encode leaked GPU buffers.');
if(errors.length)throw Error(errors.join('\n'));
return {psnr:result.psnr,descriptor:result.descriptor,files:[...result.files].map(([name,data])=>({name,bytes:data.length})),stats:encoder.runtime.stats,difference,hash,errors};
}finally{await encoder.dispose();}
};
</script>
Loading