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.
- package/README.md +171 -0
- package/index.js +164 -0
- 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
|
+
[](https://www.npmjs.com/package/headless-three)
|
|
8
|
+
[](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
|
+
}
|