headless-three 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +171 -0
  2. package/index.js +164 -0
  3. package/package.json +47 -0
package/README.md ADDED
@@ -0,0 +1,171 @@
1
+ # headless-three
2
+
3
+ Headless Three.js rendering for Node.js, made simple.
4
+ Render 3D scenes to images on the server with no browser required.
5
+ Runs Three.js r162 (the last version with WebGL 1 support) without polluting the global scope.
6
+
7
+ [![npm version](https://badge.fury.io/js/headless-three.svg)](https://www.npmjs.com/package/headless-three)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
9
+
10
+ ## Features
11
+
12
+ * Three.js r162 running in an isolated VM context
13
+ * No global scope pollution
14
+ * Works with any canvas library ([skia-canvas](https://www.npmjs.com/package/skia-canvas), [canvas](https://www.npmjs.com/package/canvas), [@napi-rs/canvas](https://www.npmjs.com/package/@napi-rs/canvas))
15
+ * Headless WebGL rendering via [gl](https://www.npmjs.com/package/gl)
16
+ * Built-in render function with multi-format output (PNG, JPEG, WebP, etc.) via [sharp](https://www.npmjs.com/package/sharp)
17
+ * Texture loading utility
18
+ * Extensible via `runInContext` for custom loaders
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ npm install headless-three
24
+ ```
25
+
26
+ You also need a canvas library:
27
+ ```bash
28
+ npm install skia-canvas
29
+ # or
30
+ npm install @napi-rs/canvas
31
+ # or
32
+ npm install canvas
33
+ ```
34
+
35
+ ## Quick Start
36
+
37
+ ```js
38
+ import { Canvas, Image, ImageData } from "skia-canvas"
39
+ import getTHREE from "headless-three"
40
+
41
+ const { THREE, render, loadTexture } = await getTHREE({ Canvas, Image, ImageData })
42
+
43
+ // Create scene
44
+ const scene = new THREE.Scene()
45
+ const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100)
46
+ camera.position.set(0, 0, 5)
47
+
48
+ // Add a lit cube
49
+ scene.add(new THREE.AmbientLight(0x404040))
50
+ const light = new THREE.DirectionalLight(0xffffff, 1)
51
+ light.position.set(-1, 2, 3)
52
+ scene.add(light)
53
+
54
+ // Load a texture
55
+ const texture = await loadTexture("path/to/texture.png")
56
+
57
+ const cube = new THREE.Mesh(
58
+ new THREE.BoxGeometry(1, 1, 1),
59
+ new THREE.MeshStandardMaterial({ map: texture })
60
+ )
61
+ scene.add(cube)
62
+
63
+ // Render to file
64
+ await render({
65
+ scene,
66
+ camera,
67
+ width: 512,
68
+ height: 512,
69
+ path: "output.png"
70
+ })
71
+
72
+ // Or get a PNG buffer
73
+ const buffer = await render({ scene, camera })
74
+ ```
75
+
76
+ ## API
77
+
78
+ ### `getTHREE(options)`
79
+
80
+ Returns a promise that resolves to `{ THREE, render, loadTexture, runInContext }`.
81
+
82
+ #### Options
83
+
84
+ | Option | Description |
85
+ |---|---|
86
+ | `Canvas` | Canvas class from your canvas library |
87
+ | `Image` | Image class from your canvas library |
88
+ | `ImageData` | ImageData class from your canvas library |
89
+
90
+ #### Returns
91
+
92
+ | Property | Description |
93
+ |---|---|
94
+ | `THREE` | The Three.js library object |
95
+ | `render(options)` | Renders a scene to an image buffer or file |
96
+ | `loadTexture(input)` | Creates a `THREE.CanvasTexture` from a Canvas, Image, ImageData, string path, or Buffer |
97
+ | `runInContext(code)` | Executes JavaScript code inside the VM context |
98
+
99
+ ### `render(options)`
100
+
101
+ Renders a scene to an image buffer or file. When saving to a file, the format is inferred from the extension unless `format` is specified. Buffer output defaults to PNG.
102
+
103
+ | Option | Default | Description |
104
+ |---|---|---|
105
+ | `scene` | | The Three.js scene to render |
106
+ | `camera` | | The camera to render from |
107
+ | `width` | `1024` | Output width in pixels |
108
+ | `height` | `1024` | Output height in pixels |
109
+ | `path` | | If provided, saves to this file path. Format is inferred from the extension |
110
+ | `format` | | Output format (`"png"`, `"jpeg"`, `"webp"`, `"avif"`, `"tiff"`, etc.). Overrides extension inference |
111
+
112
+ ```js
113
+ // Save to file (format inferred from extension)
114
+ await render({
115
+ scene,
116
+ camera,
117
+ width: 512,
118
+ height: 512,
119
+ path: "output.png"
120
+ })
121
+
122
+ // Save as JPEG
123
+ await render({ scene, camera, path: "output.jpg" })
124
+
125
+ // Force format regardless of extension
126
+ await render({ scene, camera, path: "output.img", format: "webp" })
127
+
128
+ // Get PNG buffer (default)
129
+ const buffer = await render({ scene, camera })
130
+
131
+ // Get JPEG buffer
132
+ const buffer = await render({ scene, camera, format: "jpeg" })
133
+ ```
134
+
135
+ ### `loadTexture(input)`
136
+
137
+ Creates a `THREE.CanvasTexture` from various input types:
138
+
139
+ ```js
140
+ // From a file path
141
+ const texture = await loadTexture("path/to/image.png")
142
+
143
+ // From an Image
144
+ const img = new Image()
145
+ img.src = "path/to/image.png"
146
+ const texture = await loadTexture(img)
147
+
148
+ // From a Canvas
149
+ const texture = await loadTexture(canvas)
150
+ ```
151
+
152
+ ### `runInContext(code)`
153
+
154
+ Executes code inside the same VM context as Three.js. This is useful for loading bundled Three.js addons:
155
+
156
+ ```js
157
+ import fs from "node:fs"
158
+
159
+ const { THREE, runInContext } = await getTHREE({ ... })
160
+
161
+ // Load a pre-bundled addon
162
+ runInContext(fs.readFileSync("GLTFLoader.bundle.js", "utf-8"))
163
+ ```
164
+
165
+ ## How It Works
166
+
167
+ headless-three uses Node.js's `vm` module to create an isolated V8 context with polyfilled browser APIs (`document`, `window`, `URL`, etc.). Three.js's CJS build runs inside this sandbox, thinking it's in a browser. The canvas library you provide handles the actual drawing surface and image operations. Rendering uses [gl](https://www.npmjs.com/package/gl) for headless WebGL and [sharp](https://www.npmjs.com/package/sharp) for image encoding.
168
+
169
+ ## License
170
+
171
+ MIT © [Ewan Howell](https://ewanhowell.com/)
package/index.js ADDED
@@ -0,0 +1,164 @@
1
+ import vm from "node:vm"
2
+ import fs from "node:fs"
3
+ import { createRequire } from "node:module"
4
+ import createContext from "gl"
5
+ import sharp from "sharp"
6
+
7
+ class Blob {
8
+ constructor(bufs, { type }) {
9
+ this.buffer = Buffer.concat(bufs.map(e => Buffer.from(e)))
10
+ this.type = type
11
+ }
12
+ }
13
+
14
+ export default async function({ Canvas, Image, ImageData }) {
15
+ const require = createRequire(import.meta.url)
16
+ const threePath = require.resolve("three")
17
+ const document = {
18
+ createElementNS(_, name) {
19
+ switch (name) {
20
+ case "canvas": {
21
+ const c = new Canvas(1, 1)
22
+ c.style = {}
23
+ c.addEventListener = function() {}
24
+ c.removeEventListener = function() {}
25
+ return c
26
+ }
27
+ case "img": {
28
+ const img = new Image
29
+ img.addEventListener = function(eventName, cb) {
30
+ switch (eventName) {
31
+ case "load":
32
+ img.onload = cb
33
+ break
34
+ case "error":
35
+ img.onerror = cb
36
+ break
37
+ }
38
+ }
39
+ img.removeEventListener = function(eventName) {
40
+ switch (eventName) {
41
+ case "load":
42
+ delete img.onload
43
+ break
44
+ case "error":
45
+ delete img.onerror
46
+ break
47
+ }
48
+ }
49
+ return img
50
+ }
51
+ default: throw new Error(`Unknown tag name: '${name}'`)
52
+ }
53
+ }
54
+ }
55
+ const window = {
56
+ document,
57
+ URL: {
58
+ createObjectURL: blob => `data:${blob.type};base64,${blob.buffer.toString("base64")}`,
59
+ revokeObjectURL: () => {}
60
+ },
61
+ requestAnimationFrame: cb => setTimeout(cb, 0),
62
+ cancelAnimationFrame: id => clearTimeout(id)
63
+ }
64
+ const threeExports = {}
65
+ const vmCtx = vm.createContext({
66
+ module: { exports: threeExports },
67
+ exports: threeExports,
68
+ document,
69
+ window,
70
+ self: window,
71
+ OffscreenCanvas: Canvas,
72
+ Image,
73
+ ImageData,
74
+ Blob: Blob,
75
+ fetch: globalThis.fetch,
76
+ Request: globalThis.Request,
77
+ Response: globalThis.Response,
78
+ Headers: globalThis.Headers,
79
+ Array,
80
+ Int8Array,
81
+ Uint8Array,
82
+ Uint8ClampedArray,
83
+ Int16Array,
84
+ Uint16Array,
85
+ Int32Array,
86
+ Uint32Array,
87
+ Float32Array,
88
+ Float64Array,
89
+ BigInt64Array,
90
+ BigUint64Array,
91
+ Map,
92
+ Set,
93
+ WeakMap,
94
+ WeakSet,
95
+ ArrayBuffer,
96
+ SharedArrayBuffer,
97
+ DataView,
98
+ setTimeout,
99
+ setInterval,
100
+ clearTimeout,
101
+ clearInterval,
102
+ requestAnimationFrame: cb => setTimeout(cb, 0),
103
+ cancelAnimationFrame: id => clearTimeout(id),
104
+ console
105
+ })
106
+ vm.runInContext(fs.readFileSync(threePath, "utf-8"), vmCtx)
107
+ const THREE = threeExports
108
+ return {
109
+ THREE,
110
+ runInContext: code => vm.runInContext(code, vmCtx),
111
+
112
+ async loadTexture(input) {
113
+ let tex
114
+ if (input instanceof Canvas) {
115
+ tex = new THREE.CanvasTexture(input)
116
+ } else {
117
+ let canvas
118
+ if (input instanceof ImageData) {
119
+ canvas = new Canvas(input.width, input.height)
120
+ const ctx = canvas.getContext("2d")
121
+ ctx.putImageData(input, 0, 0)
122
+ } else if (input instanceof Image) {
123
+ canvas = new Canvas(input.width, input.height)
124
+ const ctx = canvas.getContext("2d")
125
+ ctx.drawImage(input, 0, 0)
126
+ } else if (typeof input === "string" || input instanceof Buffer) {
127
+ const img = await new Promise((resolve, reject) => {
128
+ const img = new Image()
129
+ img.onload = () => resolve(img)
130
+ img.onerror = reject
131
+ img.src = input
132
+ })
133
+ canvas = new Canvas(img.width, img.height)
134
+ const ctx = canvas.getContext("2d")
135
+ ctx.drawImage(img, 0, 0)
136
+ }
137
+ tex = new THREE.CanvasTexture(canvas)
138
+ }
139
+ return tex
140
+ },
141
+
142
+ async render({ scene, camera, width = 1024, height = 1024, path, format }) {
143
+ const glCtx = createContext(width, height)
144
+ const renderer = new THREE.WebGLRenderer({ context: glCtx })
145
+ renderer.setSize(width, height)
146
+ camera.projectionMatrix.elements[5] *= -1
147
+ const gl = renderer.getContext()
148
+ const currentFrontFace = gl.getParameter(gl.FRONT_FACE)
149
+ gl.frontFace(currentFrontFace === gl.CCW ? gl.CW : gl.CCW)
150
+ renderer.render(scene, camera)
151
+ gl.frontFace(currentFrontFace)
152
+ camera.projectionMatrix.elements[5] *= -1
153
+ const pixels = new Uint8Array(width * height * 4)
154
+ glCtx.readPixels(0, 0, width, height, glCtx.RGBA, glCtx.UNSIGNED_BYTE, pixels)
155
+ renderer.dispose()
156
+ glCtx.getExtension("STACKGL_destroy_context")?.destroy()
157
+ let image = sharp(Buffer.from(pixels.buffer), { raw: { width, height, channels: 4 } })
158
+ if (format) image = image[format]()
159
+ if (path) return image.toFile(path)
160
+ if (!format) image = image.png()
161
+ return image.toBuffer()
162
+ }
163
+ }
164
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "headless-three",
3
+ "version": "1.0.0",
4
+ "description": "Headless Three.js rendering for Node.js, made simple.",
5
+ "author": "Ewan Howell <ewanhowell5195> & CCCode",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "main": "index.js",
9
+ "exports": {
10
+ ".": "./index.js"
11
+ },
12
+ "files": [
13
+ "index.js"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/ewanhowell5195/headless-three.git"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/ewanhowell5195/headless-three/issues"
21
+ },
22
+ "scripts": {
23
+ "test": "node tests/test.js skia-canvas && node tests/test.js canvas && node tests/test.js @napi-rs/canvas",
24
+ "example": "node examples/cube.js"
25
+ },
26
+ "dependencies": {
27
+ "gl": "^8.1.6",
28
+ "sharp": "^0.34.5",
29
+ "three": "0.162.0"
30
+ },
31
+ "devDependencies": {
32
+ "@napi-rs/canvas": "^0.1.97",
33
+ "canvas": "^3.2.3",
34
+ "skia-canvas": "^3.0.8"
35
+ },
36
+ "keywords": [
37
+ "three",
38
+ "threejs",
39
+ "three.js",
40
+ "node",
41
+ "headless",
42
+ "webgl",
43
+ "rendering",
44
+ "3d",
45
+ "server-side"
46
+ ]
47
+ }