Files
websperiments/standalone/gradient-editor.js

461 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const exportShaderBtn = document.getElementById('exportShader');
const exportJSONBtn = document.getElementById('exportJSON');
const exportESBtn = document.getElementById('exportES');
const exportDialog = document.getElementById('exportDialog');
const exportText = document.getElementById('exportText');
exportText.addEventListener('wheel', (e) => {
const atTop = exportText.scrollTop === 0;
const atBottom =
exportText.scrollTop + exportText.clientHeight >= exportText.scrollHeight;
if ((e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom)) {
e.preventDefault();
}
}, { passive: false });
const gradientCanvas = document.getElementById('gradientCanvas');
const gctx = gradientCanvas.getContext('2d', { willReadFrequently: true });
const pickerCanvas = document.getElementById('picker');
const pctx = pickerCanvas.getContext('2d', { willReadFrequently: true });
const addBtn = document.getElementById('addPoint');
const removeBtn = document.getElementById('removePoint');
const tbody = document.querySelector('#pointsTable tbody');
// Helpers
const clamp = (v,min,max)=>Math.min(max,Math.max(min,v));
const lerp = (a,b,t)=>a+(b-a)*t;
const toHex2 = (n)=>n.toString(16).padStart(2,'0');
function rgbToHex(r,g,b){ return '#' + toHex2(r) + toHex2(g) + toHex2(b); }
function hexToRgb(hex){
if(!hex) return null;
hex = hex.trim();
const m3 = /^#?([0-9a-fA-F]{3})$/;
const m6 = /^#?([0-9a-fA-F]{6})$/;
let m;
if(m = hex.match(m3)){
const s=m[1];
const r=parseInt(s[0]+s[0],16), g=parseInt(s[1]+s[1],16), b=parseInt(s[2]+s[2],16);
return {r,g,b};
}
if(m = hex.match(m6)){
const s=m[1];
const r=parseInt(s.slice(0,2),16), g=parseInt(s.slice(2,4),16), b=parseInt(s.slice(4,6),16);
return {r,g,b};
}
return null;
}
const pointsTable = document.getElementById('pointsTable');
pointsTable.addEventListener('wheel', (e) => {
const { target } = e;
if (target.type === 'number') {
e.preventDefault();
const dir = Math.sign(e.deltaY);
if (dir === 0) return;
const step = e.shiftKey ? 0.10 : 0.01;
const weight = parseFloat(target.value);
target.value = Math.max(0.01, +(weight - dir * step).toFixed(4));
target.dispatchEvent(new Event('input', { bubbles: true }));
} else {
return;
}
}, { passive: false });
function drawPicker(){
const w=pickerCanvas.width, h=pickerCanvas.height;
const hue = pctx.createLinearGradient(0,0,w,0);
hue.addColorStop(0/6,'#f00');
hue.addColorStop(1/6,'#ff0');
hue.addColorStop(2/6,'#0f0');
hue.addColorStop(3/6,'#0ff');
hue.addColorStop(4/6,'#00f');
hue.addColorStop(5/6,'#f0f');
hue.addColorStop(1,'#f00');
pctx.fillStyle=hue; pctx.fillRect(0,0,w,h);
const whiteGrad=pctx.createLinearGradient(0,0,0,h);
whiteGrad.addColorStop(0,'rgba(255,255,255,1)');
whiteGrad.addColorStop(0.5,'rgba(255,255,255,0)');
pctx.fillStyle=whiteGrad; pctx.fillRect(0,0,w,h);
const blackGrad=pctx.createLinearGradient(0,0,0,h);
blackGrad.addColorStop(0.5,'rgba(0,0,0,0)');
blackGrad.addColorStop(1,'rgba(0,0,0,1)');
pctx.fillStyle=blackGrad; pctx.fillRect(0,0,w,h);
}
// Model: list of control points, each { hex, weight }
let points = [
{ hex:'#008', weight:0.75 }, // was #00F, now #008
{ hex:'#8000ff', weight:0.75 },
{ hex:'#ff0080', weight:1.00 },
{ hex:'#ff8000', weight:0.75 },
{ hex:'#ffffff', weight:1.00 },
];
// Selection state
let selected = 0;
function segmentWeights(pts){
const n=pts.length; const seg=[];
for(let i=0;i<n-1;i++) seg.push((pts[i].weight + pts[i+1].weight)/2);
return seg;
}
function positionsFromWeights(pts){
const seg = segmentWeights(pts);
const sum = seg.reduce((a,b)=>a+b,0) || 1;
const len = seg.map(s=>s/sum);
const t=[0];
for(let i=0;i<len.length;i++) t.push(t[t.length-1]+len[i]);
t[t.length-1]=1; // exact endpoint
return t; // size == pts.length
}
function drawGradient(){
const w = gradientCanvas.width, h = gradientCanvas.height;
const t = positionsFromWeights(points);
const img = gctx.createImageData(w,1);
const data = img.data;
// Precompute RGB from hex
const rgb = points.map(p=>hexToRgb(p.hex) || {r:0,g:0,b:0});
let si = 0; // segment index
for(let x=0;x<w;x++){
const u = x/(w-1);
// advance segment
while(si < t.length-2 && u > t[si+1]) si++;
const a = t[si], b = t[si+1];
const v = b>a ? (u - a)/(b - a) : 0;
const r = Math.round(lerp(rgb[si].r, rgb[si+1].r, v));
const g = Math.round(lerp(rgb[si].g, rgb[si+1].g, v));
const bch = Math.round(lerp(rgb[si].b, rgb[si+1].b, v));
const k = x*4;
data[k+0]=r; data[k+1]=g; data[k+2]=bch; data[k+3]=255;
}
// scale to full height
gctx.putImageData(img,0,0);
gctx.imageSmoothingEnabled=false;
gctx.drawImage(gradientCanvas,0,0,w,h);
}
function setSelected(i){
if (selected === i) return;
const rows = [...tbody.querySelectorAll('tr')];
if (rows[selected]) rows[selected].classList.remove('sel');
selected = i;
if (rows[selected]) rows[selected].classList.add('sel');
}
function refreshPositions(){
const t = positionsFromWeights(points);
const rows = tbody.querySelectorAll('tr');
for (let j = 0; j < rows.length; j++) {
const posCell = rows[j].lastElementChild; // position column
if (posCell) posCell.textContent = t[j].toFixed(6);
}
drawGradient();
}
// Update swatches (and non-focused hex inputs) to match current point colors,
// then redraw the gradient. Does not rebuild the table or steal focus.
function refreshColors(){
const rows = tbody.querySelectorAll('tr');
for (let j = 0; j < points.length; j++) {
const row = rows[j];
if (!row) continue;
const swatch = row.querySelector('.swatch');
if (swatch) swatch.style.background = points[j].hex;
const hexInput = row.querySelector('input[type="text"]');
if (hexInput && document.activeElement !== hexInput) {
hexInput.value = points[j].hex; // keep focused input untouched
}
}
drawGradient();
}
function updateTable(){
tbody.innerHTML='';
const t = positionsFromWeights(points);
points.forEach((p,i)=>{
const tr=document.createElement('tr');
if(i===selected) tr.classList.add('sel');
const tdIdx=document.createElement('td'); tdIdx.textContent=i+1; tr.appendChild(tdIdx);
const tdSw=document.createElement('td');
const sw=document.createElement('span'); sw.className='swatch'; sw.style.background=p.hex; tdSw.appendChild(sw); tr.appendChild(tdSw);
const tdHex=document.createElement('td');
const inpHex=document.createElement('input'); inpHex.type='text'; inpHex.value=p.hex; inpHex.placeholder='#rrggbb';
/* Possible refactor:
inpHex.addEventListener('change', () => {
const rgb = hexToRgb(inpHex.value);
if (rgb) {
p.hex = normalizeHex(inpHex.value);
drawGradient();
// remove updateTable() here
} else {
inpHex.value = p.hex;
}
});
*/
tdHex.appendChild(inpHex); tr.appendChild(tdHex);
const tdW=document.createElement('td');
const inpW=document.createElement('input'); inpW.type='number'; inpW.min='0.01'; inpW.step='0.01'; inpW.value=p.weight.toString();
tdW.appendChild(inpW); tr.appendChild(tdW);
const tdPos=document.createElement('td');
tdPos.textContent = t[i].toFixed(6);
tr.appendChild(tdPos);
tr.addEventListener('click', (e) => {
setSelected(i);
});
inpHex.addEventListener('focus', () => setSelected(i));
inpHex.addEventListener('change',()=>{
const rgb=hexToRgb(inpHex.value);
if(rgb){ p.hex = normalizeHex(inpHex.value); refreshColors(); }
else { inpHex.value=p.hex; }
});
inpHex.addEventListener('input',()=>{
const rgb=hexToRgb(inpHex.value);
if(rgb){ p.hex = normalizeHex(inpHex.value); refreshColors(); }
});
inpW.addEventListener('focus', () => setSelected(i));
inpW.addEventListener('input',()=>{ p.weight = Math.max(0.01, parseFloat(inpW.value)||1); refreshPositions(); });
inpW.addEventListener('change',()=>{ p.weight = Math.max(0.01, parseFloat(inpW.value)||1); refreshPositions(); });
tbody.appendChild(tr);
});
removeBtn.disabled = points.length<=2 || selected==null;
}
function normalizeHex(h){
h=h.trim(); if(h[0]!=='#') h='#'+h; // allow missing '#'
const rgb=hexToRgb(h); if(!rgb) return '#000000';
return rgbToHex(rgb.r,rgb.g,rgb.b);
}
function updateSelection(){ updateTable(); drawGradient(); }
function addPointAfter(idx){
const t = positionsFromWeights(points);
const left = Math.max(0, Math.min(idx, points.length-1));
const right = Math.min(left+1, points.length-1);
const u = (t[left] + t[right]) / 2;
const col = sampleGradient(u);
points.splice(left+1,0,{ hex: col, weight: 1.0 });
selected = left+1; updateSelection();
}
function sampleGradient(u){
const t = positionsFromWeights(points);
const rgb = points.map(p => hexToRgb(p.hex));
let i = 0;
while (i < t.length - 2 && u > t[i+1]) i++;
const a = t[i], b = t[i+1];
const v = b > a ? (u - a) / (b - a) : 0;
const rr = Math.round(lerp(rgb[i].r, rgb[i+1].r, v));
const gg = Math.round(lerp(rgb[i].g, rgb[i+1].g, v));
const bb = Math.round(lerp(rgb[i].b, rgb[i+1].b, v));
return rgbToHex(rr, gg, bb);
}
addBtn.addEventListener('click',()=>{
const idx = selected ?? (points.length-2);
addPointAfter(idx);
});
removeBtn.addEventListener('click',()=>{
if(points.length<=2 || selected==null) return;
points.splice(selected,1);
selected = Math.max(0, Math.min(selected, points.length-1));
updateSelection();
});
pickerCanvas.addEventListener('click', (e)=>{
if(selected==null) return;
const rect = pickerCanvas.getBoundingClientRect();
const x = Math.floor((e.clientX - rect.left) * (pickerCanvas.width/rect.width));
const y = Math.floor((e.clientY - rect.top) * (pickerCanvas.height/rect.height));
const px = pctx.getImageData(x,y,1,1).data;
const hex = rgbToHex(px[0],px[1],px[2]);
points[selected].hex = hex; updateSelection();
});
// Initial render
drawPicker();
drawGradient();
updateTable();
function buildESGradientFunction() {
// t[0] = 0, t[n1] = 1
const t = positionsFromWeights(points); // [0, 0.222222, 0.481481, 0.740741, 1, …]
const rgb = points.map(p => hexToRgb(p.hex)); // [{r,g,b}, …]
const out = [];
out.push("function gradient(t) {");
out.push(" t = Math.max(0, Math.min(1, t));");
const segCount = points.length - 1; // number of segments
for (let i = 0; i < segCount; i++) {
const a = t[i];
const b = t[i + 1];
const c0 = rgb[i];
const c1 = rgb[i + 1];
// Condition
const cond =
i === 0
? `if (t < ${b})`
: i === segCount - 1
? `else`
: `else if (t < ${b})`;
out.push(` ${cond} {`);
const ratio = `(t - ${a}) / (${b} - ${a})`;
out.push(` const r = Math.round(${c0.r} + (${c1.r} - ${c0.r}) * ${ratio});`);
out.push(` const g = Math.round(${c0.g} + (${c1.g} - ${c0.g}) * ${ratio});`);
out.push(` const b = Math.round(${c0.b} + (${c1.b} - ${c0.b}) * ${ratio});`);
out.push(" return { r, g, b };");
out.push(" }");
}
out.push("}");
return out.join("\n");
}
function buildShaderFunction() {
const t = positionsFromWeights(points);
const rgb = points.map(p => hexToRgb(p.hex));
let out = [];
out.push("float3 gradient(float t)");
out.push("{");
out.push("\tt = saturate(t);");
for (let i = 0; i < points.length - 1; i++) {
const a = t[i].toFixed(6);
const b = t[i+1].toFixed(6);
const c0 = rgb[i];
const c1 = rgb[i+1];
// FIX: last segment must be unconditional
out.push(
i === 0
? `\tif (t < ${b})`
: (i === points.length - 2
? `\telse`
: `\telse if (t < ${b})`)
);
out.push("\t\treturn lerp(");
out.push(`\t\t\tfloat3(${(c0.r/255).toFixed(6)}, ${(c0.g/255).toFixed(6)}, ${(c0.b/255).toFixed(6)}),`);
out.push(`\t\t\tfloat3(${(c1.r/255).toFixed(6)}, ${(c1.g/255).toFixed(6)}, ${(c1.b/255).toFixed(6)}),`);
out.push(`\t\t\t(t - ${a}) / (${b} - ${a})`);
out.push("\t\t);");
}
out.push("}");
return out.join("\n");
}
function showExportDialog({ title, text, filename, mime }) {
const dlg = document.getElementById('exportDialog');
const ta = document.getElementById('exportText');
const h = document.getElementById('exportTitle');
const copy = document.getElementById('copyExport');
const dl = document.getElementById('downloadExport');
h.textContent = title;
ta.value = text;
copy.onclick = async () => {
await navigator.clipboard.writeText(text);
};
dl.onclick = () => {
downloadText(filename, text, mime);
};
dlg.showModal();
}
function downloadText(filename, text, mime = "text/plain") {
const blob = new Blob([text], { type: mime });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
}
function buildJSON() {
return JSON.stringify(
{ points: points.map(p => ({ hex: p.hex, weight: p.weight })) },
null,
2
);
}
exportShaderBtn.addEventListener('click', () => {
showExportDialog({
title: "OBS shader: gradient()",
text: buildShaderFunction(),
filename: "gradient.hlsl",
mime: "text/plain"
});
});
exportJSONBtn.addEventListener('click', () => {
showExportDialog({
title: "Gradient JSON",
text: buildJSON(),
filename: "gradient.json",
mime: "application/json"
});
});
exportESBtn.addEventListener('click', () => {
showExportDialog({
title: "EcmaScript: gradient()",
text: buildESGradientFunction(),
filename: "gradient.js",
mime: "application/js"
});
});