three-slug 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/GOAL.md +32 -0
- package/README.md +78 -0
- package/demo/fonts/DejaVu Fonts License.txt +97 -0
- package/demo/fonts/DejaVuSansMono-Bold.sluggish +0 -0
- package/demo/fonts/DejaVuSansMono-Bold.ttf +0 -0
- package/demo/fonts/DejaVuSansMono-BoldOblique.ttf +0 -0
- package/demo/fonts/DejaVuSansMono-Oblique.ttf +0 -0
- package/demo/fonts/DejaVuSansMono.ttf +0 -0
- package/demo/fonts/Roboto-Italic-VariableFont_wdth,wght.ttf +0 -0
- package/demo/fonts/Roboto-VariableFont_wdth,wght.ttf +0 -0
- package/demo/fonts/SpaceMono-Bold.ttf +0 -0
- package/demo/fonts/SpaceMono-BoldItalic.ttf +0 -0
- package/demo/fonts/SpaceMono-Italic.ttf +0 -0
- package/demo/fonts/SpaceMono-Regular.sluggish +0 -0
- package/demo/fonts/SpaceMono-Regular.ttf +0 -0
- package/demo/fonts/fonts.json +12 -0
- package/demo/index.html +103 -0
- package/demo/main.js +357 -0
- package/package.json +29 -0
- package/screenshot.png +0 -0
- package/src/SlugGenerator.js +399 -0
- package/src/SlugGeometry.js +211 -0
- package/src/SlugLoader.js +123 -0
- package/src/SlugMaterial.js +254 -0
- package/src/index.js +4 -0
package/demo/main.js
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
|
|
2
|
+
import * as THREE from 'three';
|
|
3
|
+
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
4
|
+
|
|
5
|
+
import { SlugMaterial, SlugGeometry, SlugGenerator, SlugLoader, injectSlug } from '../src/index.js';
|
|
6
|
+
|
|
7
|
+
// Only handles some generic unicode:
|
|
8
|
+
// 漢字 ॐ ♞ ♠ ♡ ♢ ♣ ☻ ☼ 🎵 🚀 (Demonstrating generic Unicode vectors)
|
|
9
|
+
// ✌️🌴🐢🐐🍄⚽🍻👑📸😬👀🚨🏡🕊️🏆😻🌟🧿🍀🎨🍜
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
let camera, scene, renderer;
|
|
13
|
+
let controls;
|
|
14
|
+
let slugMesh;
|
|
15
|
+
let spotLight;
|
|
16
|
+
let pointLight;
|
|
17
|
+
let debugCube;
|
|
18
|
+
let loadedData = null;
|
|
19
|
+
let loadedFileName = 'font';
|
|
20
|
+
|
|
21
|
+
init();
|
|
22
|
+
animate();
|
|
23
|
+
|
|
24
|
+
function init() {
|
|
25
|
+
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
|
|
26
|
+
renderer.setPixelRatio(window.devicePixelRatio);
|
|
27
|
+
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
28
|
+
renderer.setClearColor(0x000000, 1.0); // Pure black background
|
|
29
|
+
renderer.shadowMap.enabled = true;
|
|
30
|
+
renderer.shadowMap.type = THREE.PCFShadowMap;
|
|
31
|
+
renderer.shadowMap.samples = 4;
|
|
32
|
+
document.body.appendChild(renderer.domElement);
|
|
33
|
+
|
|
34
|
+
// Swap to Perspective camera to fly around
|
|
35
|
+
camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 10000);
|
|
36
|
+
camera.position.set(307, -500, 400);
|
|
37
|
+
|
|
38
|
+
scene = new THREE.Scene();
|
|
39
|
+
|
|
40
|
+
const ambientLight = new THREE.AmbientLight(0x404040, 0.2); // Very low ambient to keep shadows dark
|
|
41
|
+
scene.add(ambientLight);
|
|
42
|
+
|
|
43
|
+
// Extreme intensity for high contrast, positioned head-on to cast perfectly square shadows onto the backend plane
|
|
44
|
+
spotLight = new THREE.SpotLight(0xffffff, 5000000.0);
|
|
45
|
+
spotLight.position.set(0, 0, 800);
|
|
46
|
+
spotLight.angle = Math.PI / 4;
|
|
47
|
+
spotLight.penumbra = 0.5;
|
|
48
|
+
spotLight.decay = 2.0;
|
|
49
|
+
spotLight.distance = 3000;
|
|
50
|
+
spotLight.castShadow = true;
|
|
51
|
+
spotLight.shadow.bias = -0.0001; // Tiny absolute depth offset to kill shadow acne
|
|
52
|
+
spotLight.shadow.normalBias = 0.05; // Slightly push the intersection along the normal
|
|
53
|
+
spotLight.shadow.mapSize.width = 2048;
|
|
54
|
+
spotLight.shadow.mapSize.height = 2048;
|
|
55
|
+
spotLight.shadow.camera.near = 10;
|
|
56
|
+
spotLight.shadow.camera.far = 2000;
|
|
57
|
+
scene.add(spotLight);
|
|
58
|
+
|
|
59
|
+
// Explicitly add target so Three.js auto-updates its matrices
|
|
60
|
+
spotLight.target.position.set(0, 0, -100);
|
|
61
|
+
scene.add(spotLight.target);
|
|
62
|
+
|
|
63
|
+
const planeGeometry = new THREE.PlaneGeometry(10000, 10000);
|
|
64
|
+
const planeMaterial = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.8 });
|
|
65
|
+
const plane = new THREE.Mesh(planeGeometry, planeMaterial);
|
|
66
|
+
plane.position.z = -10; // Moved explicitly further back so depth bounds are 100% unambiguous in shadow map
|
|
67
|
+
plane.receiveShadow = true;
|
|
68
|
+
scene.add(plane);
|
|
69
|
+
|
|
70
|
+
// Floating Debug Cube to cast shadows on text
|
|
71
|
+
const cubeGeo = new THREE.BoxGeometry(20, 20, 20);
|
|
72
|
+
const cubeMat = new THREE.MeshStandardMaterial({ color: 0xff3333, roughness: 0.1 });
|
|
73
|
+
debugCube = new THREE.Mesh(cubeGeo, cubeMat);
|
|
74
|
+
debugCube.position.set(0, 0, 50);
|
|
75
|
+
debugCube.castShadow = true;
|
|
76
|
+
scene.add(debugCube);
|
|
77
|
+
|
|
78
|
+
// Small blue point light that orbits the debug cube
|
|
79
|
+
pointLight = new THREE.PointLight(0x00aaff, 200000.0, 2500); // Increased intensity + reach radius
|
|
80
|
+
const bulbGeo = new THREE.SphereGeometry(10, 16, 8); // Larger visible sphere
|
|
81
|
+
const bulbMat = new THREE.MeshBasicMaterial({ color: 0x00aaff });
|
|
82
|
+
pointLight.add(new THREE.Mesh(bulbGeo, bulbMat));
|
|
83
|
+
pointLight.castShadow = true;
|
|
84
|
+
pointLight.shadow.bias = -0.0001;
|
|
85
|
+
scene.add(pointLight);
|
|
86
|
+
|
|
87
|
+
controls = new OrbitControls(camera, renderer.domElement);
|
|
88
|
+
controls.enableDamping = true;
|
|
89
|
+
controls.target.set(207, 0, 0);
|
|
90
|
+
window.addEventListener('resize', onWindowResize);
|
|
91
|
+
|
|
92
|
+
document.getElementById('fileSluggish').addEventListener('change', handleSluggishUpload);
|
|
93
|
+
document.getElementById('fileTtf').addEventListener('change', handleTtfUpload);
|
|
94
|
+
document.getElementById('btnDownload').addEventListener('click', handleDownload);
|
|
95
|
+
document.getElementById('textInput').addEventListener('input', () => {
|
|
96
|
+
if (loadedData) createTextMesh();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
document.getElementById('fontSelect').addEventListener('change', (e) => {
|
|
100
|
+
loadFont(e.target.value);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
document.getElementById('textInput').addEventListener('input', () => {
|
|
104
|
+
if (loadedData) createTextMesh();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
document.getElementById('useRawShader').addEventListener('change', () => {
|
|
108
|
+
if (loadedData) createTextMesh();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
document.getElementById('textJustify').addEventListener('change', () => {
|
|
112
|
+
if (loadedData) createTextMesh();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Prepopulate textarea with our own source code to demonstrate large paragraphs
|
|
116
|
+
fetch('./main.js')
|
|
117
|
+
.then(response => response.text())
|
|
118
|
+
.then(text => {
|
|
119
|
+
const textArea = document.getElementById('textInput');
|
|
120
|
+
textArea.value = text; // 1x copies
|
|
121
|
+
if (loadedData) createTextMesh();
|
|
122
|
+
})
|
|
123
|
+
.catch(err => console.error("Could not load main.js for textarea", err));
|
|
124
|
+
|
|
125
|
+
// Dynamically populate font select from index
|
|
126
|
+
const select = document.getElementById('fontSelect');
|
|
127
|
+
fetch('./fonts/fonts.json')
|
|
128
|
+
.then(r => r.json())
|
|
129
|
+
.then(fonts => {
|
|
130
|
+
fonts.forEach(f => {
|
|
131
|
+
const opt = document.createElement('option');
|
|
132
|
+
opt.value = f;
|
|
133
|
+
opt.innerText = f.replace(/\.[^/.]+$/, "");
|
|
134
|
+
select.appendChild(opt);
|
|
135
|
+
});
|
|
136
|
+
if (select.children.length > 0) {
|
|
137
|
+
loadFont(select.value);
|
|
138
|
+
} else {
|
|
139
|
+
loadFont('DejaVuSansMono.ttf');
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
.catch(err => {
|
|
143
|
+
console.error("Could not load fonts.json list:", err);
|
|
144
|
+
loadFont('DejaVuSansMono.ttf'); // Fallback
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function loadFont(fontName) {
|
|
149
|
+
try {
|
|
150
|
+
console.log(`Loading font: ${fontName}...`);
|
|
151
|
+
const response = await fetch(`./fonts/${fontName}`);
|
|
152
|
+
const buffer = await response.arrayBuffer();
|
|
153
|
+
const generator = new SlugGenerator();
|
|
154
|
+
loadedData = await generator.generateFromBuffer(buffer);
|
|
155
|
+
loadedFileName = fontName.replace(/\.[^/.]+$/, "");
|
|
156
|
+
console.log(`Loaded font data for ${fontName}:`, loadedData);
|
|
157
|
+
createTextMesh();
|
|
158
|
+
} catch (err) {
|
|
159
|
+
console.error(`Failed to load font ${fontName}`, err);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function handleSluggishUpload(event) {
|
|
164
|
+
const file = event.target.files[0];
|
|
165
|
+
if (!file) return;
|
|
166
|
+
|
|
167
|
+
const reader = new FileReader();
|
|
168
|
+
reader.onload = function (e) {
|
|
169
|
+
const buffer = e.target.result;
|
|
170
|
+
try {
|
|
171
|
+
const loader = new SlugLoader();
|
|
172
|
+
loadedData = loader.parse(buffer);
|
|
173
|
+
console.log("Loaded Sluggish Data:", loadedData);
|
|
174
|
+
createTextMesh();
|
|
175
|
+
} catch (err) {
|
|
176
|
+
console.error(err);
|
|
177
|
+
alert("Error parsing sluggish file: " + err.message);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
reader.readAsArrayBuffer(file);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function handleTtfUpload(event) {
|
|
184
|
+
const file = event.target.files[0];
|
|
185
|
+
if (!file) return;
|
|
186
|
+
|
|
187
|
+
loadedFileName = file.name.replace(/\.[^/.]+$/, "");
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
console.log("Generating slug data from TTF...");
|
|
191
|
+
const generator = new SlugGenerator();
|
|
192
|
+
loadedData = await generator.generateFromFile(file);
|
|
193
|
+
console.log("Generated Data:", loadedData);
|
|
194
|
+
createTextMesh();
|
|
195
|
+
} catch (err) {
|
|
196
|
+
console.error(err);
|
|
197
|
+
alert("Error generating sluggish data: " + err.message);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function handleDownload() {
|
|
202
|
+
if (!loadedData || !loadedData._raw) {
|
|
203
|
+
alert("No generated TTF data to download. Please load a .ttf file first.");
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const generator = new SlugGenerator();
|
|
207
|
+
const buffer = generator.exportSluggish(loadedData);
|
|
208
|
+
|
|
209
|
+
const blob = new Blob([buffer], { type: "application/octet-stream" });
|
|
210
|
+
const url = URL.createObjectURL(blob);
|
|
211
|
+
const a = document.createElement('a');
|
|
212
|
+
a.href = url;
|
|
213
|
+
a.download = loadedFileName + ".sluggish";
|
|
214
|
+
document.body.appendChild(a);
|
|
215
|
+
a.click();
|
|
216
|
+
document.body.removeChild(a);
|
|
217
|
+
URL.revokeObjectURL(url);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function createTextMesh() {
|
|
221
|
+
if (!loadedData) return;
|
|
222
|
+
|
|
223
|
+
if (slugMesh) {
|
|
224
|
+
scene.remove(slugMesh);
|
|
225
|
+
slugMesh.geometry.dispose();
|
|
226
|
+
slugMesh.material.dispose();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const useRawFallback = document.getElementById('useRawShader').checked;
|
|
230
|
+
let material;
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
const textToRender = document.getElementById('textInput').value;
|
|
235
|
+
const justifyMode = document.getElementById('textJustify').value;
|
|
236
|
+
|
|
237
|
+
// Dynamically size the geometry capacity to exactly the number of characters
|
|
238
|
+
// Add a minimum of 1 to avoid Three.js throwing empty buffer errors
|
|
239
|
+
const geometry = new SlugGeometry(Math.max(1, textToRender.length));
|
|
240
|
+
|
|
241
|
+
geometry.clear();
|
|
242
|
+
|
|
243
|
+
geometry.addText(textToRender, loadedData, {
|
|
244
|
+
fontScale: 0.5,
|
|
245
|
+
startX: 0,
|
|
246
|
+
startY: 0,
|
|
247
|
+
justify: justifyMode
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
if (useRawFallback) {
|
|
251
|
+
material = new SlugMaterial({
|
|
252
|
+
curvesTex: loadedData.curvesTex,
|
|
253
|
+
bandsTex: loadedData.bandsTex
|
|
254
|
+
});
|
|
255
|
+
// No custom shadow material for raw shader fallback
|
|
256
|
+
} else {
|
|
257
|
+
material = new THREE.MeshStandardMaterial({
|
|
258
|
+
color: 0xffaa00, // Golden yellow
|
|
259
|
+
roughness: 1.0, // Pure diffuse plastic surface to properly scatter un-angled SpotLight luminance
|
|
260
|
+
metalness: 0.0, // Removing metalness prevents the flat quads from reflecting the void into a dark mirror
|
|
261
|
+
side: THREE.DoubleSide
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
let newSlugMesh = new THREE.Mesh(geometry, material);
|
|
266
|
+
if (slugMesh) {
|
|
267
|
+
newSlugMesh.position.copy(slugMesh.position);
|
|
268
|
+
}
|
|
269
|
+
slugMesh = newSlugMesh;
|
|
270
|
+
|
|
271
|
+
// Apply the architectural PBR macros and instantiate Shared Global Shadow maps securely
|
|
272
|
+
injectSlug(slugMesh, material, loadedData);
|
|
273
|
+
|
|
274
|
+
// Let Three.js dynamically build the Depth Material derived from our onBeforeCompile graph instead of forcing custom
|
|
275
|
+
slugMesh.castShadow = true;
|
|
276
|
+
slugMesh.receiveShadow = true;
|
|
277
|
+
|
|
278
|
+
// Disabling frustum culling temporally to purely isolate WebGL shadow buffer pipelines
|
|
279
|
+
//slugMesh.frustumCulled = false;
|
|
280
|
+
slugMesh.scale.multiplyScalar(.02);
|
|
281
|
+
scene.add(slugMesh);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function onWindowResize() {
|
|
285
|
+
camera.aspect = window.innerWidth / window.innerHeight;
|
|
286
|
+
camera.updateProjectionMatrix();
|
|
287
|
+
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function animate() {
|
|
291
|
+
requestAnimationFrame(animate);
|
|
292
|
+
|
|
293
|
+
// Star Wars Title Crawl
|
|
294
|
+
if (slugMesh) {
|
|
295
|
+
const autoScroll = document.getElementById('autoScroll');
|
|
296
|
+
if (autoScroll && autoScroll.checked) {
|
|
297
|
+
const speed = 1.7;
|
|
298
|
+
slugMesh.position.y += speed;
|
|
299
|
+
if (slugMesh.position.y > 1500) {
|
|
300
|
+
slugMesh.position.y -= 1000;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const glitchCheckbox = document.getElementById('matrixGlitch');
|
|
305
|
+
if (glitchCheckbox && glitchCheckbox.checked && loadedData) {
|
|
306
|
+
if (Math.random() < 0.25) { // 25% chance per frame to glitch
|
|
307
|
+
let currentText = document.getElementById('textInput').value.split('');
|
|
308
|
+
let numGlitches = Math.floor(Math.random() * 1800) + 1;
|
|
309
|
+
const keys = Array.from(loadedData.codePoints.keys()).filter(k => k > 32); // Exclude space and hidden ones
|
|
310
|
+
|
|
311
|
+
for (let i = 0; i < numGlitches; i++) {
|
|
312
|
+
let idx = Math.floor(Math.random() * currentText.length);
|
|
313
|
+
if (currentText[idx] !== '\n' && currentText[idx] !== ' ') {
|
|
314
|
+
let randomChar = String.fromCodePoint(keys[Math.floor(Math.random() * keys.length)]);
|
|
315
|
+
currentText[idx] = randomChar;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
slugMesh.geometry.clear();
|
|
320
|
+
slugMesh.geometry.addText(currentText.join(''), loadedData, {
|
|
321
|
+
fontScale: 0.5,
|
|
322
|
+
startX: 0,
|
|
323
|
+
startY: 0,
|
|
324
|
+
justify: document.getElementById('textJustify').value
|
|
325
|
+
});
|
|
326
|
+
slugMesh.userData.isGlitched = true;
|
|
327
|
+
}
|
|
328
|
+
} else if (slugMesh.userData.isGlitched) {
|
|
329
|
+
// Restore original clean text immediately when unchecked
|
|
330
|
+
slugMesh.geometry.clear();
|
|
331
|
+
slugMesh.geometry.addText(document.getElementById('textInput').value, loadedData, {
|
|
332
|
+
fontScale: 0.5,
|
|
333
|
+
startX: 0,
|
|
334
|
+
startY: 0,
|
|
335
|
+
justify: document.getElementById('textJustify').value
|
|
336
|
+
});
|
|
337
|
+
slugMesh.userData.isGlitched = false;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (debugCube) {
|
|
342
|
+
// Keep debug cube visible in frame and spinning wildly
|
|
343
|
+
debugCube.position.y = 50;
|
|
344
|
+
debugCube.position.x = Math.sin(Date.now() * 0.001) * 300;
|
|
345
|
+
debugCube.rotation.x += 0.01;
|
|
346
|
+
debugCube.rotation.y += 0.02;
|
|
347
|
+
|
|
348
|
+
if (pointLight) {
|
|
349
|
+
pointLight.position.x = debugCube.position.x + Math.cos(Date.now() * 0.0015) * 300;
|
|
350
|
+
pointLight.position.y = debugCube.position.y + Math.sin(Date.now() * 0.0015) * 300;
|
|
351
|
+
pointLight.position.z = debugCube.position.z + Math.cos(Date.now() * 0.001) * 150;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (controls) controls.update();
|
|
356
|
+
renderer.render(scene, camera);
|
|
357
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"dependencies": {
|
|
3
|
+
"opentype.js": "^1.3.4"
|
|
4
|
+
},
|
|
5
|
+
"name": "three-slug",
|
|
6
|
+
"version": "1.0.0",
|
|
7
|
+
"description": "JSlug is a Javascript and WebGL port of Eric Lengyel's **Slug** font rendering algorithm, implemented for **Three.js**.",
|
|
8
|
+
"main": "src/index.js",
|
|
9
|
+
"module": "src/index.js",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.js"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"peerDependencies": {
|
|
15
|
+
"three": ">=0.160.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/manthrax/JSlug.git"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [],
|
|
23
|
+
"author": "manthrax",
|
|
24
|
+
"license": "ISC",
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/manthrax/JSlug/issues"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/manthrax/JSlug#readme"
|
|
29
|
+
}
|
package/screenshot.png
ADDED
|
Binary file
|