feat: Add new Geometry Nodes groups for image relief and shadeless shader

- Implemented the "O Image Relief Plane" Geometry Nodes group to create a fixed-base image relief using depth images and various parameters.
- Created the "O Shadeless" shader node group to display color without scene lighting while preserving shadows and reflections.
- Introduced a preview feature for node groups, allowing for interactive visualization of node arrangements in an HTML format.
- Added a template HTML file for rendering node previews with customizable views and controls.
This commit is contained in:
2026-09-09 21:24:57 +08:00
parent f5aff50b13
commit 89087fc2e2
118 changed files with 5635 additions and 3130 deletions

View File

@@ -0,0 +1,279 @@
<div id="node-layout-results">
<h3>节点排列 · 尺寸模型预览</h3>
<div class="viz-controls">
<label class="form-label">节点组
<select class="form-select" id="layout-group"></select>
</label>
<label class="form-label">视野
<select class="form-select" id="layout-view">
<option value="all">完整排列</option>
<option value="input">起点输入附近</option>
<option value="middle">中段计算局部</option>
<option value="join">Join Geometry 附近</option>
<option value="menu">Menu Switch 附近</option>
<option value="combine">Combine XYZ 附近</option>
<option value="boolean">Boolean Math 附近</option>
<option value="sample">Sample Index 附近</option>
<option value="zone">成对节点内部范围</option>
<option value="output">最终输出附近</option>
</select>
</label>
</div>
<div class="viz-row">
<button class="btn" type="button" id="layout-out" aria-label="缩小">−</button>
<button class="btn" type="button" id="layout-in" aria-label="放大">+</button>
<span class="text-small tabular-nums" id="layout-scale"></span>
<span class="text-small">拖动平移 · 滚轮缩放 · 点击节点查看名称</span>
</div>
<canvas id="layout-canvas" role="img" aria-label="按实际排列坐标绘制的节点组,尺寸和曲线使用估算模型"></canvas>
<div class="viz-row text-small" id="layout-legend">
<span><i class="geometry-key"></i>几何连接</span>
<span><i class="field-key"></i>字段与参数连接</span>
<span id="layout-count"></span>
</div>
<div class="text-small" id="layout-detail" aria-live="polite">完整排列</div>
</div>
<style>
#node-layout-results { --geometry-color: #00d6a3; --field-color: #535353; --node-body: #282828; --node-text: #ededed; --zone-color: #b986d3; }
#node-layout-results canvas { display: block; width: 100%; height: 550px; touch-action: none; cursor: grab; margin: 8px 0; }
#node-layout-results canvas:active { cursor: grabbing; }
#node-layout-results .geometry-key, #node-layout-results .field-key { display: inline-block; width: 18px; height: 2px; vertical-align: middle; margin-right: 5px; }
#node-layout-results .geometry-key { background: var(--geometry-color); }
#node-layout-results .field-key { background: var(--muted-foreground); }
#node-layout-results #layout-detail { min-height: 2em; overflow-wrap: anywhere; }
@media (max-width: 440px) { #node-layout-results canvas { height: 450px; } }
</style>
<script type="application/json" id="layout-data">__NODE_DATA__</script>
<script>
(() => {
const root = document.getElementById('node-layout-results');
const data = JSON.parse(document.getElementById('layout-data').textContent);
const canvas = root.querySelector('#layout-canvas');
const context = canvas.getContext('2d');
const picker = root.querySelector('#layout-group');
const view = root.querySelector('#layout-view');
const detail = root.querySelector('#layout-detail');
let current = data[0], selected = null, scale = 1, dx = 0, dy = 0, width = 0, height = 0;
let dragging = null, moved = false;
data.forEach((group, index) => {
const option = document.createElement('option');
option.value = index; option.textContent = group.name; picker.appendChild(option);
});
const probe = document.createElement('span');
probe.hidden = true; root.appendChild(probe);
function color(token) {
probe.style.color = `var(${token})`;
return getComputedStyle(probe).color;
}
function bounds(nodes) {
return [Math.min(...nodes.map(n => n.x)), Math.min(...nodes.map(n => n.y)),
Math.max(...nodes.map(n => n.x + n.w)), Math.max(...nodes.map(n => n.y + n.h))];
}
function fit(box) {
const [left, top, right, bottom] = box;
scale = Math.min((width - 36) / (right - left), (height - 36) / (bottom - top));
dx = (width - (right + left) * scale) / 2;
dy = (height - (bottom + top) * scale) / 2;
draw();
}
function updateZoneViews() {
view.querySelectorAll('[data-zone]').forEach(option => option.remove());
current.zones.forEach(zone => {
const option = document.createElement('option');
option.value = `zone:${zone.entry}`; option.dataset.zone = 'true';
option.textContent = `${current.nodes[zone.entry].name} → ${current.nodes[zone.exit].name}`;
view.appendChild(option);
});
}
function changeView() {
selected = null;
if (view.value === 'all') {
fit(bounds([...current.nodes, ...current.zones]));
detail.textContent = '完整排列';
return;
}
if (view.value === 'zone' || view.value.startsWith('zone:')) {
if (current.zones.length) {
const zones = view.value === 'zone' ? current.zones : current.zones.filter(zone => `zone:${zone.entry}` === view.value);
fit(bounds(zones)); detail.textContent = zones.map(zone => `${current.nodes[zone.entry].name} → ${current.nodes[zone.exit].name}`).join(' · ');
} else {
fit(bounds(current.nodes)); detail.textContent = `${current.name} · 当前组无成对节点`;
}
return;
}
if (view.value === 'middle') {
const geometryNodes = current.nodes.filter(n => n.type !== 'NodeReroute' && n.role === 'geometry');
const midX = (Math.min(...geometryNodes.map(n => n.x)) + Math.max(...geometryNodes.map(n => n.x))) / 2;
const target = geometryNodes.sort((a, b) => Math.abs(a.x - midX) - Math.abs(b.x - midX))[0];
fit([target.x - 850, target.y - 560, target.x + 670, target.y + 600]);
detail.textContent = `${current.name} · 中段计算局部`;
return;
}
if (view.value === 'input') {
const target = current.nodes.filter(n => n.role === 'geometry' && n.type !== 'NodeReroute').sort((a, b) => a.x - b.x)[0];
fit([target.x - 850, target.y - 650, target.x + 750, target.y + 650]);
detail.textContent = `${current.name} · 起点输入附近`; return;
}
const types = {join: 'GeometryNodeJoinGeometry', menu: 'GeometryNodeMenuSwitch', combine: 'ShaderNodeCombineXYZ', boolean: 'FunctionNodeBooleanMath', sample: 'GeometryNodeSampleIndex', output: 'NodeGroupOutput'};
let candidates = current.nodes.filter(n => n.type === types[view.value]);
if (view.value === 'menu') {
const geometryMenus = candidates.filter(n => n.sockets.some(s => s[2] && s[3] === 'GEOMETRY'));
if (geometryMenus.length) candidates = geometryMenus;
}
if (!candidates.length) {
fit(bounds(current.nodes)); detail.textContent = `${current.name} · 当前组无此类节点`; return;
}
const target = candidates.sort((a, b) => b.x - a.x)[0] || current.nodes.at(-1);
fit([target.x - 1100, target.y - 560, target.x + 420, target.y + 600]);
detail.textContent = `${target.name} · ${target.title}`;
}
function label(text, x, y, maxWidth, align = 'left') {
if (maxWidth < 24) return;
let value = String(text);
if (context.measureText(value).width > maxWidth) {
while (value.length && context.measureText(value + '…').width > maxWidth) value = value.slice(0, -1);
value += '…';
}
context.textAlign = align;
context.fillText(value, x, y);
}
function draw() {
if (!width || !height) return;
const ratio = window.devicePixelRatio || 1;
context.setTransform(ratio, 0, 0, ratio, 0, 0);
context.clearRect(0, 0, width, height);
const foreground = color('--foreground');
const border = color('--border'), fields = color('--muted-foreground');
const geometry = color('--geometry-color'), other = color('--field-color');
const font = getComputedStyle(root).fontFamily;
const size = Math.max(11, parseFloat(getComputedStyle(root).fontSize) * 0.86);
context.font = `400 ${size}px ${font}`;
context.textBaseline = 'middle';
context.strokeStyle = border; context.lineWidth = 1;
context.strokeRect(0.5, 0.5, width - 1, height - 1);
context.save(); context.beginPath(); context.rect(1, 1, width - 2, height - 2); context.clip();
for (const zone of current.zones) {
const x = zone.x * scale + dx, y = zone.y * scale + dy;
const w = zone.w * scale, h = zone.h * scale;
context.fillStyle = color('--zone-color'); context.globalAlpha = 0.065;
const heading = w > 90 && h > size + 12 ? size + 4 : 0;
context.beginPath(); context.roundRect(x, y - heading, w, h + heading, Math.min(24 * scale, 12)); context.fill();
context.globalAlpha = 0.55; context.strokeStyle = color('--zone-color'); context.lineWidth = 1; context.stroke();
context.globalAlpha = 1;
if (heading) {
context.fillStyle = foreground; label(zone.title, x + 8, y - heading / 2, w - 16);
}
}
for (const edge of current.links) {
const [source, target, wx1, wy1, wx2, wy2, type] = edge;
const x1 = wx1 * scale + dx, y1 = wy1 * scale + dy;
const x2 = wx2 * scale + dx, y2 = wy2 * scale + dy;
const bend = Math.max(40, Math.abs(wx2 - wx1) * 0.42) * scale;
const active = selected === source || selected === target;
context.globalAlpha = selected !== null && !active ? 0.18 : type === 'GEOMETRY' ? 0.85 : 0.46;
context.strokeStyle = type === 'GEOMETRY' ? geometry : fields;
context.lineWidth = active ? 2.5 : type === 'GEOMETRY' ? 1.35 : 0.8;
context.beginPath(); context.moveTo(x1, y1);
context.bezierCurveTo(x1 + bend, y1, x2 - bend, y2, x2, y2); context.stroke();
}
context.globalAlpha = 1;
for (const node of current.nodes) {
const x = node.x * scale + dx, y = node.y * scale + dy, w = node.w * scale, h = node.h * scale;
if (x + w < 0 || x > width || y + h < 0 || y > height) continue;
const geometryNode = node.type === 'NodeReroute'
? node.sockets.some(socket => socket[3] === 'GEOMETRY') : node.role === 'geometry';
const hue = geometryNode ? geometry : other;
if (node.type === 'NodeReroute') {
context.fillStyle = hue; context.beginPath();
context.arc(x + w / 2, y + h / 2, Math.max(1.5, w / 3), 0, Math.PI * 2); context.fill(); continue;
}
context.fillStyle = color('--node-body');
context.beginPath(); context.roundRect(x, y, w, h, Math.min(node.folded ? h / 2 : 5, 5)); context.fill();
context.fillStyle = hue; context.globalAlpha = 0.11; context.fill(); context.globalAlpha = 1;
context.strokeStyle = selected === node.id ? foreground : border;
context.lineWidth = selected === node.id ? 2 : 0.8; context.stroke();
if (!node.folded) {
context.fillStyle = hue; context.globalAlpha = 0.26;
context.fillRect(x + 0.5, y + 0.5, Math.max(0, w - 1), Math.min(32 * scale, h - 1));
context.globalAlpha = 1;
}
if (w >= 78 && h >= size + 5) {
context.save(); context.beginPath(); context.rect(x + 3, y, w - 6, h); context.clip();
context.fillStyle = color('--node-text');
label(node.title, x + 7, node.folded ? y + h / 2 : y + Math.max(size / 2 + 3, 16 * scale), w - 14);
if (!node.folded && scale >= 0.52) {
for (const [name, offset, output, type, value] of node.sockets) {
context.fillStyle = color('--node-text');
label(value ? `${name}: ${value}` : name, output ? x + w - 8 : x + 8,
y + offset * scale, w - 16, output ? 'right' : 'left');
}
const outputs = node.sockets.filter(s => s[2]);
const firstInput = node.sockets.find(s => !s[2]);
let row = (outputs.length ? Math.max(...outputs.map(s => s[1])) + 26 : 56);
for (const setting of node.settings) {
if (firstInput && row > firstInput[1] - 24) break;
context.fillStyle = fields; label(setting, x + 8, y + row * scale, w - 16); row += 24;
}
}
context.restore();
}
if (scale >= 0.22) {
for (const socket of node.sockets) {
context.fillStyle = socket[3] === 'GEOMETRY' ? geometry : fields;
context.beginPath(); context.arc(socket[2] ? x + w : x, y + socket[1] * scale,
Math.max(1.4, 3.6 * scale), 0, Math.PI * 2); context.fill();
}
}
}
context.restore();
root.querySelector('#layout-scale').textContent = `${Math.round(scale * 100)}%`;
root.querySelector('#layout-count').textContent = `${current.nodes.length} 节点 · ${current.links.length} 连接`;
canvas.setAttribute('aria-label', `${current.name},${current.nodes.length} 个节点,${current.links.length} 条连接。几何路径与字段支线按函数输出坐标绘制。`);
}
function zoom(factor, x = width / 2, y = height / 2) {
const next = Math.max(0.01, Math.min(2.5, scale * factor));
dx = x - (x - dx) * next / scale; dy = y - (y - dy) * next / scale; scale = next; draw();
}
picker.addEventListener('change', () => {
if (view.value.startsWith('zone:')) view.value = 'zone';
current = data[Number(picker.value)]; updateZoneViews(); changeView();
});
view.addEventListener('change', changeView);
root.querySelector('#layout-in').addEventListener('click', () => zoom(1.5));
root.querySelector('#layout-out').addEventListener('click', () => zoom(1 / 1.5));
canvas.addEventListener('wheel', event => {
event.preventDefault(); const rect = canvas.getBoundingClientRect();
zoom(Math.exp(-event.deltaY * 0.0015), event.clientX - rect.left, event.clientY - rect.top);
}, { passive: false });
canvas.addEventListener('pointerdown', event => {
dragging = [event.clientX, event.clientY, dx, dy]; moved = false;
canvas.setPointerCapture(event.pointerId);
});
canvas.addEventListener('pointermove', event => {
if (!dragging) return;
const x = event.clientX - dragging[0], y = event.clientY - dragging[1];
if (Math.abs(x) + Math.abs(y) > 4) moved = true;
dx = dragging[2] + x; dy = dragging[3] + y; draw();
});
canvas.addEventListener('pointerup', event => {
if (!moved) {
const rect = canvas.getBoundingClientRect();
const x = (event.clientX - rect.left - dx) / scale, y = (event.clientY - rect.top - dy) / scale;
const node = current.nodes.findLast(n => x >= n.x && x <= n.x + n.w && y >= n.y && y <= n.y + n.h);
selected = node ? node.id : null;
detail.textContent = node ? `${node.name} · ${node.title} · ${node.role === 'geometry' ? '几何主线' : '计算或输入'}${node.settings.length ? ' · ' + node.settings.join(' / ') : ''}` : current.name;
draw();
}
dragging = null;
});
canvas.addEventListener('pointercancel', () => { dragging = null; });
new ResizeObserver(() => {
const rect = canvas.getBoundingClientRect(); width = rect.width; height = rect.height;
const ratio = window.devicePixelRatio || 1;
canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio); changeView();
}).observe(canvas);
new MutationObserver(draw).observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] });
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', draw);
updateZoneViews();
})();
</script>