diff --git a/src/graph/context.ts b/src/graph/context.ts index 22642e2..e6ab89d 100644 --- a/src/graph/context.ts +++ b/src/graph/context.ts @@ -5,6 +5,7 @@ import { AstNode, FunctionNode, Program } from '@shaderfrog/glsl-parser/ast'; import { Engine, EngineContext, + EngineNodeType, extendNodeContext, extendNodesContext, } from '../engine'; @@ -79,6 +80,21 @@ const computeNodeContext = async ( graph: Graph, node: SourceNode ): Promise => { + // Three engine nodes have no GLSL source — return a minimal empty context. + // Their compilation is handled via onBeforeCompile on the material (threngine.ts). + if ( + (node as CodeNode).engine && + [EngineNodeType.physical, EngineNodeType.phong, EngineNodeType.toon].includes( + node.type as EngineNodeType + ) + ) { + return { + ast: { type: 'program', program: [], scopes: [] } as Program, + inputFillers: {}, + inputs: node.inputs, + }; + } + // THIS DUPLICATES OTHER LINE const parser = { ...(coreParsers[node.type] || coreParsers[NodeType.SOURCE]), diff --git a/src/graph/graph.ts b/src/graph/graph.ts index e052362..30deb87 100644 --- a/src/graph/graph.ts +++ b/src/graph/graph.ts @@ -3,7 +3,7 @@ import { renameFunctions, } from '@shaderfrog/glsl-parser/parser/utils'; import { Program } from '@shaderfrog/glsl-parser/ast'; -import { Engine, EngineContext } from '../engine'; +import { Engine, EngineContext, EngineNodeType } from '../engine'; import { NodeContext, NodeContexts, @@ -15,6 +15,7 @@ import { shaderSectionsCons, findShaderSections, mergeShaderSections, + filterSections, ShaderSections, shaderSectionsToProgram, } from './shader-sections'; @@ -538,6 +539,17 @@ export const compileNode = ( continuation = mergeShaderSections(continuation, inputSections); compiledIds = { ...compiledIds, ...childIds }; + // Three engine nodes have no AST to fill — user GLSL accumulates in + // continuation above and is injected via onBeforeCompile at material time. + if ( + (codeNode as CodeNode).engine && + [EngineNodeType.physical, EngineNodeType.phong, EngineNodeType.toon].includes( + node.type as EngineNodeType + ) + ) { + return; + } + // Continue on if there's no context I don't know what case causes this if (!nodeContext) { return; @@ -615,6 +627,16 @@ export const compileNode = ( return [sections, filler, { ...compiledIds, [node.id]: node }]; }; +export type ThreeNodeFiller = { + fillerName: string; // e.g. 'filler_map', 'filler_position' + fromNodeId: string; // upstream user node feeding this filler +}; + +export type ThreeNodeInfo = { + nodeType: EngineNodeType; + connectedFillers: ThreeNodeFiller[]; +}; + export type CompileGraphResult = { fragment: ShaderSections; vertex: ShaderSections; @@ -622,6 +644,7 @@ export type CompileGraphResult = { outputVert: GraphNode; orphanNodes: GraphNode[]; activeNodeIds: Set; + threeNode?: ThreeNodeInfo; }; export const compileGraph = ( @@ -679,6 +702,46 @@ export const compileGraph = ( outputVert ); + // Find connected Three engine nodes and their active fillers for onBeforeCompile + const threeEngineNodeTypes = [ + EngineNodeType.physical, + EngineNodeType.phong, + EngineNodeType.toon, + ]; + const threeEngineNodes = graph.nodes.filter( + (n): n is SourceNode => + (n as CodeNode).engine === true && + threeEngineNodeTypes.includes(n.type as EngineNodeType) + ); + + let threeNode: ThreeNodeInfo | undefined; + if (threeEngineNodes.length > 0) { + const nodeType = threeEngineNodes[0].type as EngineNodeType; + const connectedFillers: ThreeNodeFiller[] = []; + + for (const engineNode of threeEngineNodes) { + for (const edge of graph.edges.filter((e) => e.to === engineNode.id)) { + const input = engineNode.inputs.find((i) => i.id === edge.input); + if (!input) continue; + + if (input.type === 'filler') { + // namedAttributeStrategy inputs (e.g. filler_position) + connectedFillers.push({ fillerName: input.id, fromNodeId: edge.from }); + } else if (input.type === 'property' && input.bakeable) { + // Bakeable property inputs (e.g. map → filler_map) + const prop = (engineNode as CodeNode).config.properties?.find( + (p) => p.property === input.property + ); + if (prop?.fillerName) { + connectedFillers.push({ fillerName: prop.fillerName, fromNodeId: edge.from }); + } + } + } + } + + threeNode = { nodeType, connectedFillers }; + } + // Every compileNode returns the AST so far, as well as the filler for the // next node with inputs. On the final step, we discard the filler return { @@ -692,6 +755,7 @@ export const compileGraph = ( ...Object.keys(fragmentIds), ...orphanNodes.map((node) => node.id), ]), + threeNode, }; }; @@ -764,11 +828,27 @@ export const compileSource = async ( const compileResult = compileGraph(updatedContext, engine, graph); + // For Three engine node graphs, filter out the output node's void main() + // so fragmentResult/vertexResult contain only user-authored GLSL functions. + // createMaterial injects these into Three's shader via onBeforeCompile. + const fragSections = compileResult.threeNode + ? filterSections( + (s) => s.nodeId !== compileResult.outputFrag.id, + compileResult.fragment + ) + : compileResult.fragment; + const vertSections = compileResult.threeNode + ? filterSections( + (s) => s.nodeId !== compileResult.outputVert.id, + compileResult.vertex + ) + : compileResult.vertex; + const fragmentResult = generate( - shaderSectionsToProgram(compileResult.fragment, engine.mergeOptions).program + shaderSectionsToProgram(fragSections, engine.mergeOptions).program ); const vertexResult = generate( - shaderSectionsToProgram(compileResult.vertex, engine.mergeOptions).program + shaderSectionsToProgram(vertSections, engine.mergeOptions).program ); const dataInputs = filterGraphNodes( diff --git a/src/plugins/three/FrogMaterial.ts b/src/plugins/three/FrogMaterial.ts new file mode 100644 index 0000000..c6462dd --- /dev/null +++ b/src/plugins/three/FrogMaterial.ts @@ -0,0 +1,276 @@ +import * as THREE from 'three'; + +// ── Shader transformation utilities ────────────────────────────────────────── + +export const expandChunks = (glsl: string, depth = 0): string => { + if (depth > 12) return glsl; + return glsl.replace(/#include <(\w+)>/g, (_, chunkName: string) => { + const chunk = (THREE.ShaderChunk as Record)[chunkName]; + if (chunk !== undefined) { + return `/* ~~~ #include <${chunkName}> ~~~ */\n${expandChunks( + chunk, + depth + 1 + )}\n/* ~~~ end <${chunkName}> ~~~ */`; + } + return `/* MISSING #include <${chunkName}> */`; + }); +}; + +export const replaceFromOffset = ( + str: string, + offset: number, + search: string | RegExp, + replacement: string +) => str.slice(0, offset) + str.slice(offset).replace(search, replacement); + +export const replaceLast = ( + str: string, + charToReplace: string, + replacement: string +) => { + const index = str.lastIndexOf(charToReplace); + if (index === -1) return str; + return ( + str.slice(0, index) + replacement + str.slice(index + charToReplace.length) + ); +}; + +// ── Injectable fragment properties ─────────────────────────────────────────── +// When a material property value is a string, it's treated as a GLSL call +// expression and injected at the corresponding point in the expanded shader. +// A dummy Texture is set on the material to force Three.js to emit the chunk. +// +// InjectableKey covers every texture-typed property from threngine. Only those +// listed in FRAGMENT_INJECTABLE have a known injection pattern; the rest accept +// strings in the TypeScript type but produce no automatic shader replacement +// (the caller can still handle them via fragmentInjections). + +type FragmentInjectionPoint = { + find: RegExp; + replace: (callExpr: string) => string; + forceProperty: string; +}; + +// All texture-typed property names across phong / physical / toon in threngine +type InjectableKey = + | 'map' + | 'normalMap' + | 'aoMap' + | 'emissiveMap' + | 'roughnessMap' + | 'specularMap' + | 'displacementMap' + | 'bumpMap' + | 'transmissionMap' + | 'gradientMap'; + +const INJECTABLE_KEYS: ReadonlySet = new Set([ + 'map', 'normalMap', 'aoMap', 'emissiveMap', 'roughnessMap', + 'specularMap', 'displacementMap', 'bumpMap', 'transmissionMap', 'gradientMap', +]); + +// Injection implementations for the properties where we know the pattern +const FRAGMENT_INJECTABLE: Partial> = { + map: { + find: /vec4 sampledDiffuseColor = [^;]+;/, + replace: (call: string) => `vec4 sampledDiffuseColor = ${call};`, + forceProperty: 'map', + }, + normalMap: { + find: /vec3 mapN = texture2D\( normalMap, vNormalMapUv \)\.xyz \* 2\.0 - 1\.0;/, + replace: (call: string) => `vec3 mapN = ${call}.rgb * 2.0 - 1.0;`, + forceProperty: 'normalMap', + }, + emissiveMap: { + find: /vec4 emissiveColor = [^;]+;/, + replace: (call: string) => `vec4 emissiveColor = ${call};`, + forceProperty: 'emissiveMap', + }, + roughnessMap: { + find: /vec4 texelRoughness = [^;]+;/, + replace: (call: string) => `vec4 texelRoughness = ${call};`, + forceProperty: 'roughnessMap', + }, + aoMap: { + find: /float ambientOcclusion = [^;]+;/, + replace: (call: string) => + `float ambientOcclusion = ( ${call}.r - 1.0 ) * aoMapIntensity + 1.0;`, + forceProperty: 'aoMap', + }, + specularMap: { + find: /vec4 texelSpecular = [^;]+;/, + replace: (call: string) => `vec4 texelSpecular = ${call};`, + forceProperty: 'specularMap', + }, +}; + +// For injectable properties, also allow a GLSL expression string +type WithInjectables

= { + [K in keyof P]: K extends InjectableKey ? P[K] | string : P[K]; +}; + +// ── Vertex position injection ───────────────────────────────────────────────── + +const POSITION_INJECTION = { + find: /vec3 transformed = vec3\( position \);/, + replace: (call: string) => `vec3 transformed = ${call}.xyz;`, +}; + +// ── FrogMaterial ────────────────────────────────────────────────────────────── + +interface ShaderInjection { + search: string; + replace: string; +} + +type MaterialConstructor = new (...args: any[]) => THREE.Material; + +type ConstructorParams = C extends new ( + params?: infer P, + ...args: any[] +) => THREE.Material + ? NonNullable

+ : never; + +type FrogSpecificKeys = + | 'baseMaterial' + | 'fragmentShader' + | 'fragmentOutput' + | 'vertexShader' + | 'vertexOutput' + | 'uniforms' + | 'fragmentInjections' + | 'position' + | 'onBeforeCompile'; + +export type FrogMaterialParams< + C extends MaterialConstructor = MaterialConstructor +> = { + baseMaterial: C; + fragmentShader: string; + fragmentOutput: string; + vertexShader: string; + vertexOutput: string; + uniforms?: Record; + fragmentInjections?: ShaderInjection[]; + /** GLSL expression replacing `vec3 transformed = vec3(position)` */ + position?: string; + /** Called after all frog transforms are applied to the shader */ + onBeforeCompile?: ( + shader: THREE.WebGLProgramParametersWithUniforms, + renderer: THREE.WebGLRenderer + ) => void; +} & Omit>, FrogSpecificKeys>; + +function _create({ + baseMaterial: BaseMaterial, + fragmentShader, + fragmentOutput, + vertexShader, + vertexOutput, + uniforms = {}, + fragmentInjections = [], + position, + onBeforeCompile: userOnBeforeCompile, + ...baseProps +}: FrogMaterialParams): THREE.Material { + // Split baseProps: injectable strings become GLSL injections + dummy textures + const glslInjections: Array<{ find: RegExp; replace: string }> = []; + const materialProps: Record = {}; + + for (const [key, value] of Object.entries( + baseProps as Record + )) { + if (INJECTABLE_KEYS.has(key) && typeof value === 'string') { + const inj = FRAGMENT_INJECTABLE[key as InjectableKey]; + if (inj) { + glslInjections.push({ find: inj.find, replace: inj.replace(value) }); + materialProps[inj.forceProperty] = new THREE.Texture(); + } else { + materialProps[key] = new THREE.Texture(); + } + } else { + materialProps[key] = value; + } + } + + const mat = new BaseMaterial(materialProps as ConstructorParams); + + mat.onBeforeCompile = (shader, renderer) => { + Object.assign(shader.uniforms, uniforms); + mat.userData.shader = shader; + + // ── Fragment ────────────────────────────────────────────────────────── + shader.fragmentShader = expandChunks(shader.fragmentShader); + + shader.fragmentShader = replaceFromOffset( + shader.fragmentShader, + shader.fragmentShader.indexOf('void main'), + /gl_FragColor/g, + 'fragColor' + ); + shader.fragmentShader = replaceLast( + shader.fragmentShader, + '}', + 'return fragColor;\n}' + ); + + shader.fragmentShader = + shader.fragmentShader.replace( + 'void main() {', + fragmentShader + + '\n\nvec4 main_MeshPhysicalMaterial() {\n vec4 fragColor = vec4(0.0);' + ) + `\n\nvoid main() { gl_FragColor = ${fragmentOutput}; }`; + + for (const { find, replace } of glslInjections) { + shader.fragmentShader = shader.fragmentShader.replace(find, replace); + } + for (const { search, replace } of fragmentInjections) { + shader.fragmentShader = shader.fragmentShader.replace(search, replace); + } + + // ── Vertex ──────────────────────────────────────────────────────────── + shader.vertexShader = expandChunks(shader.vertexShader); + + shader.vertexShader = replaceFromOffset( + shader.vertexShader, + shader.vertexShader.indexOf('void main'), + 'gl_Position', + 'fragPosition' + ); + shader.vertexShader = replaceLast( + shader.vertexShader, + '}', + 'return fragPosition;\n}' + ); + + shader.vertexShader = + shader.vertexShader.replace( + 'void main() {', + vertexShader + + '\n\nvec4 main_MeshPhysicalMaterial() {\n vec4 fragPosition = vec4(0.0);' + ) + `\n\nvoid main() { ${vertexOutput} }`; + + if (position) { + shader.vertexShader = shader.vertexShader.replace( + POSITION_INJECTION.find, + POSITION_INJECTION.replace(position) + ); + } + + userOnBeforeCompile?.(shader, renderer); + }; + + return mat; +} + +// Interface merge: TypeScript sees FrogMaterial as extending THREE.Material, +// so instances can be used wherever THREE.Material is expected. +export interface FrogMaterial< + C extends MaterialConstructor = MaterialConstructor +> extends THREE.Material {} +export class FrogMaterial { + constructor(params: FrogMaterialParams) { + return _create(params) as unknown as FrogMaterial; + } +} diff --git a/src/plugins/three/index.ts b/src/plugins/three/index.ts index 7a87e8c..52b268d 100644 --- a/src/plugins/three/index.ts +++ b/src/plugins/three/index.ts @@ -1,3 +1,5 @@ import { createMaterial, threngine } from './threngine'; export { createMaterial, threngine as engine }; +export { FrogMaterial, expandChunks, replaceFromOffset, replaceLast } from './FrogMaterial'; +export type { FrogMaterialParams } from './FrogMaterial'; diff --git a/src/plugins/three/threeExport.ts b/src/plugins/three/threeExport.ts new file mode 100644 index 0000000..6ad8dc0 --- /dev/null +++ b/src/plugins/three/threeExport.ts @@ -0,0 +1,192 @@ +import { ShaderChunk } from 'three'; +import { CompileGraphResult, ThreeNodeInfo } from '../../graph/graph'; +import { Graph } from '../../graph/graph-types'; +import { nodeName } from '../../graph/graph'; +import { threeInjectionMaps, stringifyThreeValue } from './threngine'; +import { EngineNodeType } from '../../engine'; + +export type ThreeExportInput = { + fragmentResult: string; + vertexResult: string; + compileResult: CompileGraphResult; + graph: Graph; + userUniforms: Record; + shaderName: string; +}; + +const extractUniformDeclarations = (glsl: string): string => + glsl + .split('\n') + .filter((l) => l.trimStart().startsWith('uniform ')) + .join('\n'); + +const stripUniformDeclarations = (glsl: string): string => + glsl + .split('\n') + .filter((l) => !l.trimStart().startsWith('uniform ')) + .join('\n'); + +const stripGlslPreamble = (glsl: string): string => + glsl + .replace(/^#version \d+ es\s*\n?/m, '') + .replace(/^\s*precision\s+\w+p\s+\w+;\s*\n?/gm, ''); + +const serializeUniforms = ( + uniforms: Record +): string => { + const entries = Object.entries(uniforms).map(([name, { value }]) => { + const valStr = stringifyThreeValue(value); + return ` ${name}: { value: ${valStr} }`; + }); + return `{\n${entries.join(',\n')}\n }`; +}; + +const updateLines = ( + uniforms: Record +): string => ` // uniforms.iTime.value += delta;\n`; + +const generateType1Export = (input: ThreeExportInput): string => { + const { fragmentResult, vertexResult, userUniforms, shaderName } = input; + const uniformsStr = serializeUniforms(userUniforms); + + return `// Generated by Shaderfrog — ${shaderName} +import * as THREE from 'three'; + +export function createMaterial() { + const uniforms = ${uniformsStr}; + + const material = new THREE.ShaderMaterial({ + uniforms, + vertexShader: ${JSON.stringify(vertexResult.replace(/^#version \d+ es\s*\n?/m, ''))}, + fragmentShader: ${JSON.stringify(fragmentResult.replace(/^#version \d+ es\s*\n?/m, ''))}, + }); + + return { + material, + update(delta) { +${updateLines(userUniforms)} }, + }; +} +`; +}; + +const generateType2Export = (input: ThreeExportInput): string => { + const { fragmentResult, vertexResult, compileResult, graph, userUniforms, shaderName } = + input; + const { threeNode } = compileResult; + if (!threeNode) return generateType1Export(input); + + const MatClassName: Record = { + [EngineNodeType.physical]: 'MeshPhysicalMaterial', + [EngineNodeType.phong]: 'MeshPhongMaterial', + [EngineNodeType.toon]: 'MeshToonMaterial', + }; + + const matClass = MatClassName[threeNode.nodeType] ?? 'MeshPhysicalMaterial'; + const injectionMap = threeInjectionMaps[threeNode.nodeType]; + + const forcingProps: string[] = []; + const fragChunkReplacements: Array<{ includeStr: string; modifiedChunk: string }> = []; + const vertChunkReplacements: Array<{ includeStr: string; modifiedChunk: string }> = []; + + for (const filler of threeNode.connectedFillers) { + const fromNode = graph.nodes.find((n) => n.id === filler.fromNodeId); + const callExpr = fromNode ? `${nodeName(fromNode)}()` : 'sfFragmentEntry()'; + const vertCallExpr = fromNode ? `${nodeName(fromNode)}()` : 'sfVertexEntry()'; + + const fragPoint = injectionMap?.fragment?.[filler.fillerName]; + if (fragPoint?.forceProperty) { + forcingProps.push(` mat.${fragPoint.forceProperty} = new THREE.Texture();`); + } + if (fragPoint) { + const chunkSource = ShaderChunk[fragPoint.chunk as keyof typeof ShaderChunk]; + if (chunkSource) { + const modifiedChunk = chunkSource.replace( + fragPoint.find, + fragPoint.replace(callExpr) + ); + fragChunkReplacements.push({ + includeStr: `#include <${fragPoint.chunk}>`, + modifiedChunk, + }); + } + } + + const vertPoint = injectionMap?.vertex?.[filler.fillerName]; + if (vertPoint) { + const chunkSource = ShaderChunk[vertPoint.chunk as keyof typeof ShaderChunk]; + if (chunkSource) { + const modifiedChunk = chunkSource.replace( + vertPoint.find, + vertPoint.replace(vertCallExpr) + ); + vertChunkReplacements.push({ + includeStr: `#include <${vertPoint.chunk}>`, + modifiedChunk, + }); + } + } + } + + const cleanFrag = stripGlslPreamble(fragmentResult); + const fragUniformDecls = extractUniformDeclarations(cleanFrag); + const fragFunctions = stripUniformDeclarations(cleanFrag); + const cleanVert = stripGlslPreamble(vertexResult); + const vertFunctions = stripUniformDeclarations(cleanVert); + const uniformsStr = serializeUniforms(userUniforms); + + const fragReplCode = fragChunkReplacements + .map( + ({ includeStr, modifiedChunk }) => + ` shader.fragmentShader = shader.fragmentShader.replace(\n ${JSON.stringify(includeStr)},\n ${JSON.stringify(modifiedChunk)}\n );` + ) + .join('\n'); + + const vertReplCode = vertChunkReplacements + .map( + ({ includeStr, modifiedChunk }) => + ` shader.vertexShader = shader.vertexShader.replace(\n ${JSON.stringify(includeStr)},\n ${JSON.stringify(modifiedChunk)}\n );` + ) + .join('\n'); + + const fragUniformsLine = fragUniformDecls.trim() + ? ` shader.fragmentShader = ${JSON.stringify(fragUniformDecls + '\n')} + shader.fragmentShader;` + : ''; + const fragFnsLine = fragFunctions.trim() + ? ` shader.fragmentShader = shader.fragmentShader.replace('void main() {', ${JSON.stringify(fragFunctions + '\nvoid main() {')} );` + : ''; + const vertFnsLine = vertFunctions.trim() + ? ` shader.vertexShader = shader.vertexShader.replace('void main() {', ${JSON.stringify(vertFunctions + '\nvoid main() {')} );` + : ''; + + return `// Generated by Shaderfrog — ${shaderName} +import * as THREE from 'three'; + +export function createMaterial() { + const uniforms = ${uniformsStr}; + + const mat = new THREE.${matClass}(); +${forcingProps.join('\n')} + + mat.onBeforeCompile = (shader) => { +${fragUniformsLine} +${fragFnsLine} +${fragReplCode} +${vertFnsLine} +${vertReplCode} + Object.assign(shader.uniforms, uniforms); + }; + + return { + material: mat, + update(delta) { +${updateLines(userUniforms)} }, + }; +} +`; +}; + +export const generateThreeExport = (input: ThreeExportInput): string => { + const { threeNode } = input.compileResult; + return threeNode ? generateType2Export(input) : generateType1Export(input); +}; diff --git a/src/plugins/three/threngine.ts b/src/plugins/three/threngine.ts index 56a0dcc..4ffeb74 100644 --- a/src/plugins/three/threngine.ts +++ b/src/plugins/three/threngine.ts @@ -1,32 +1,29 @@ import { - ShaderLib, - RawShaderMaterial, + ShaderChunk, + ShaderMaterial, + Material, Vector2, Vector3, Vector4, Color, - GLSL3, - Light, Texture, MeshPhongMaterial, MeshPhysicalMaterial, MeshToonMaterial, Scene, - WebGLRenderer, PerspectiveCamera, + WebGLRenderer, } from 'three'; import { Program } from '@shaderfrog/glsl-parser/ast'; import { Graph, NodeType, ShaderStage } from '../../graph/graph-types'; -import { prepopulatePropertyInputs, mangleMainFn } from '../../graph/graph'; -import importers from './importers'; - -import { Engine, EngineContext, EngineNodeType } from '../../engine'; -import { doesLinkThruShader, CompileResult } from '../../graph/graph'; +import { prepopulatePropertyInputs, nodeName, doesLinkThruShader, CompileGraphResult } from '../../graph/graph'; import { returnGlPosition, - returnGlPositionHardCoded, returnGlPositionVec3Right, } from '../../util/ast'; +import importers from './importers'; + +import { Engine, EngineContext, EngineNodeType } from '../../engine'; import { CodeNode, NodeProperty, @@ -40,12 +37,132 @@ import { texture2DStrategy, uniformStrategy, } from '../../strategy'; -import { NodeParser } from '../../graph/parsers'; -import indexById from '../../util/indexByid'; const log = (...args: any[]) => console.log.call(console, '\x1b[35m(three)\x1b[0m', ...args); +export type InjectionPoint = { + chunk: string; + find: RegExp; + replace: (callExpr: string) => string; + forceProperty?: string; +}; + +export type InjectionMap = Partial>; + +export const threeInjectionMaps: Partial< + Record +> = { + [EngineNodeType.physical]: { + fragment: { + filler_map: { + chunk: 'map_fragment', + find: /vec4 sampledDiffuseColor = [^;]+;/, + replace: (call) => `vec4 sampledDiffuseColor = ${call};`, + forceProperty: 'map', + }, + filler_normalMap: { + chunk: 'normal_fragment_maps', + find: /vec3 mapN = texture2D\( normalMap, vNormalMapUv \)\.xyz \* 2\.0 - 1\.0;/, + replace: (call) => `vec3 mapN = ${call}.rgb * 2.0 - 1.0;`, + forceProperty: 'normalMap', + }, + filler_emissiveMap: { + chunk: 'emissivemap_fragment', + find: /vec4 emissiveColor = [^;]+;/, + replace: (call) => `vec4 emissiveColor = ${call};`, + forceProperty: 'emissiveMap', + }, + filler_roughnessMap: { + chunk: 'roughnessmap_fragment', + find: /vec4 texelRoughness = [^;]+;/, + replace: (call) => `vec4 texelRoughness = ${call};`, + forceProperty: 'roughnessMap', + }, + filler_aoMap: { + chunk: 'aomap_fragment', + find: /float ambientOcclusion = [^;]+;/, + replace: (call) => + `float ambientOcclusion = ( ${call}.r - 1.0 ) * aoMapIntensity + 1.0;`, + forceProperty: 'aoMap', + }, + }, + vertex: { + filler_position: { + chunk: 'begin_vertex', + find: /vec3 transformed = vec3\( position \);/, + replace: (call) => `vec3 transformed = ${call}.xyz;`, + }, + }, + }, + [EngineNodeType.phong]: { + fragment: { + filler_map: { + chunk: 'map_fragment', + find: /vec4 sampledDiffuseColor = [^;]+;/, + replace: (call) => `vec4 sampledDiffuseColor = ${call};`, + forceProperty: 'map', + }, + filler_normalMap: { + chunk: 'normal_fragment_maps', + find: /vec3 mapN = texture2D\( normalMap, vNormalMapUv \)\.xyz \* 2\.0 - 1\.0;/, + replace: (call) => `vec3 mapN = ${call}.rgb * 2.0 - 1.0;`, + forceProperty: 'normalMap', + }, + filler_emissiveMap: { + chunk: 'emissivemap_fragment', + find: /vec4 emissiveColor = [^;]+;/, + replace: (call) => `vec4 emissiveColor = ${call};`, + forceProperty: 'emissiveMap', + }, + filler_aoMap: { + chunk: 'aomap_fragment', + find: /float ambientOcclusion = [^;]+;/, + replace: (call) => + `float ambientOcclusion = ( ${call}.r - 1.0 ) * aoMapIntensity + 1.0;`, + forceProperty: 'aoMap', + }, + }, + vertex: { + filler_position: { + chunk: 'begin_vertex', + find: /vec3 transformed = vec3\( position \);/, + replace: (call) => `vec3 transformed = ${call}.xyz;`, + }, + }, + }, + [EngineNodeType.toon]: { + fragment: { + filler_map: { + chunk: 'map_fragment', + find: /vec4 sampledDiffuseColor = [^;]+;/, + replace: (call) => `vec4 sampledDiffuseColor = ${call};`, + forceProperty: 'map', + }, + filler_normalMap: { + chunk: 'normal_fragment_maps', + find: /vec3 mapN = texture2D\( normalMap, vNormalMapUv \)\.xyz \* 2\.0 - 1\.0;/, + replace: (call) => `vec3 mapN = ${call}.rgb * 2.0 - 1.0;`, + forceProperty: 'normalMap', + }, + filler_aoMap: { + chunk: 'aomap_fragment', + find: /float ambientOcclusion = [^;]+;/, + replace: (call) => + `float ambientOcclusion = ( ${call}.r - 1.0 ) * aoMapIntensity + 1.0;`, + forceProperty: 'aoMap', + }, + }, + vertex: { + filler_position: { + chunk: 'begin_vertex', + find: /vec3 transformed = vec3\( position \);/, + replace: (call) => `vec3 transformed = ${call}.xyz;`, + }, + }, + }, +}; + export const phongNode = ( id: string, name: string, @@ -244,266 +361,6 @@ export const physicalNode = ( stage, }); -const cacher = ( - engineContext: EngineContext, - graph: Graph, - node: SourceNode, - sibling: SourceNode | undefined, - newValue: (...args: any[]) => any -) => { - const cacheKey = programCacheKey(engineContext, graph, node, sibling); - - if (engineContext.runtime.cache.data[cacheKey]) { - log('Cache hit', cacheKey); - } else { - log('Cache miss', cacheKey); - } - const materialData = engineContext.runtime.cache.data[cacheKey] || newValue(); - - // This is nasty: We mutate the runtime context here, and return the partial - // nodeContext later. See also the TODO: Refactor in context.ts. - engineContext.runtime.cache.data[cacheKey] = materialData; - engineContext.runtime.engineMaterial = materialData.material; - - return { - computedSource: - node.stage === 'fragment' ? materialData.fragment : materialData.vertex, - }; -}; - -const onBeforeCompileMegaShader = ( - engineContext: EngineContext, - newMat: any -) => { - log('compiling three megashader!'); - const { renderer, sceneData, scene, camera } = engineContext.runtime; - const { mesh } = sceneData; - - // Temporarily swap the mesh material to the new one, since materials can - // be mesh specific, render, then get its source code - const originalMaterial = mesh.material; - mesh.material = newMat; - renderer.compile(scene, camera); - - // The references to the compiled shaders in WebGL - const fragmentRef = renderer.properties - .get(mesh.material) - .programs.values() - .next().value.fragmentShader; - const vertexRef = renderer.properties - .get(mesh.material) - .programs.values() - .next().value.vertexShader; - - const gl = renderer.getContext(); - const fragment = gl.getShaderSource(fragmentRef); - const vertex = gl.getShaderSource(vertexRef); - - // Reset the material on the mesh, since the shader we're computing context - // for might not be the one actually want on the mesh - like if a toon node - // was added to the graph but not connected - mesh.material = originalMaterial; - - // Do we even need to do this? This is just for debugging right? Using the - // source on the node is the important thing. - return { - material: newMat, - fragmentRef, - vertexRef, - fragment, - vertex, - }; -}; - -const megaShaderMainpulateAst: NodeParser['manipulateAst'] = ( - engineContext, - engine, - graph, - ast, - inputEdges, - node, - sibling -) => { - const programAst = ast as Program; - const mainName = 'main'; // || nodeName(node); - if (node.stage === 'vertex') { - if (doesLinkThruShader(graph, node)) { - returnGlPositionHardCoded(mainName, programAst, 'vec3', 'transformed'); - } else { - returnGlPosition(mainName, programAst); - } - } - - // We specify engine nodes are mangle: false, which is the graph step that - // handles renaming the main fn, so we have to do it ourselves - mangleMainFn(programAst, node, sibling); - return programAst; -}; - -const nodeCacheKey = (graph: Graph, node: SourceNode) => { - return ( - '[ID:' + - node.id + - 'Edges:' + - graph.edges - .filter((edge) => edge.to === node.id) - .map((edge) => `(${edge.to}->${edge.input})`) - .sort() - .join(',') + - ']' - // Currently excluding node inputs because these are calculated *after* - // the onbeforecompile, so the next compile, they'll all change! - // node.inputs.map((i) => `${i.id}${i.bakeable}`) - ); -}; - -const programCacheKey = ( - engineContext: EngineContext, - graph: Graph, - node: SourceNode, - sibling?: SourceNode -) => { - // The megashader source is dependent on scene information, like the number - // and type of lights in the scene. This kinda sucks - it's duplicating - // three's material cache key, and is coupled to how three builds shaders - const { scene } = engineContext.runtime; - const lights: string[] = []; - scene.traverse((obj: any) => { - if (obj instanceof Light) { - lights.push(obj.uuid); - } - }); - - return ( - ([node, sibling] as SourceNode[]) - .filter((n) => !!n) - .sort((a, b) => a.id.localeCompare(b.id)) - .map((n) => nodeCacheKey(graph, n)) - .join('-') + - '|Lights:' + - lights.join(',') + - '|Bg:' + - scene.background?.uuid + - '|Env:' + - scene.environment?.uuid - ); -}; - -export const defaultPropertySetting = (property: NodeProperty) => { - if (property.type === 'texture') { - return new Texture(); - } else if (property.type === 'number') { - return 0.5; - } else if (property.type === 'rgb') { - return new Color(1, 1, 1); - } else if (property.type === 'rgba') { - return new Vector4(1, 1, 1, 1); - } -}; - -const threeMaterialProperties = ( - graph: Graph, - node: SourceNode, - sibling?: SourceNode -): Record => { - // Find inputs to this node that are dependent on a property of the material - const propertyInputs = indexById(node.inputs.filter((i) => i.property)); - - // Then look for any edges into those inputs and set the material property - return graph.edges - .filter((edge) => edge.to === node.id || edge.to === sibling?.id) - .reduce>((acc, edge) => { - // Check if we've plugged into an input for a property - const propertyInput = propertyInputs[edge.input]; - if (propertyInput) { - // Find the property itself - const property = (node.config.properties || []).find( - (p) => p.property === propertyInput.property - ) as NodeProperty; - - // Initialize the property on the material - acc[property.property] = defaultPropertySetting(property); - } - return acc; - }, {}); -}; - -export type ThreeRuntime = { - scene: Scene; - camera: PerspectiveCamera; - renderer: WebGLRenderer; - sceneData: any; - engineMaterial: any; - loaded: boolean; - index: number; - cache: { - data: { - [key: string]: any; - }; - nodes: { - [id: string]: { - fragmentRef: any; - vertexRef: any; - fragment: string; - vertex: string; - }; - }; - }; -}; - -export const stringifyThreeValue = (input: any): string => { - if (input instanceof Vector2) { - return `new Vector2(${input.x}, ${input.y})`; - } else if (input instanceof Vector3) { - return `new Vector3(${input.x}, ${input.y}, ${input.z})`; - } else if (input instanceof Vector4) { - return `new Vector4(${input.x}, ${input.y}, ${input.z}, ${input.w})`; - } else if (input instanceof Color) { - return `new Color(${input.r}, ${input.g}, ${input.b})`; - } else if (input instanceof Texture) { - return `new Texture()`; - } - return `${input}`; -}; - -const evaluateNode = (node: DataNode) => { - if (node.type === 'number') { - return parseFloat(node.value); - } - - if (node.type === 'vector2') { - return new Vector2(parseFloat(node.value[0]), parseFloat(node.value[1])); - } else if (node.type === 'vector3') { - return new Vector3( - parseFloat(node.value[0]), - parseFloat(node.value[1]), - parseFloat(node.value[2]) - ); - } else if (node.type === 'vector4') { - return new Vector4( - parseFloat(node.value[0]), - parseFloat(node.value[1]), - parseFloat(node.value[2]), - parseFloat(node.value[3]) - ); - } else if (node.type === 'rgb') { - return new Color( - parseFloat(node.value[0]), - parseFloat(node.value[1]), - parseFloat(node.value[2]) - ); - } else if (node.type === 'rgba') { - return new Vector4( - parseFloat(node.value[0]), - parseFloat(node.value[1]), - parseFloat(node.value[2]), - parseFloat(node.value[3]) - ); - } else { - return node.value; - } -}; - export const toonNode = ( id: string, name: string, @@ -575,6 +432,68 @@ export const toonNode = ( stage, }); +export type ThreeRuntime = { + scene: Scene; + camera: PerspectiveCamera; + renderer: WebGLRenderer; + sceneData: any; + loaded: boolean; + index: number; +}; + +export const stringifyThreeValue = (input: any): string => { + if (input instanceof Vector2) { + return `new Vector2(${input.x}, ${input.y})`; + } else if (input instanceof Vector3) { + return `new Vector3(${input.x}, ${input.y}, ${input.z})`; + } else if (input instanceof Vector4) { + return `new Vector4(${input.x}, ${input.y}, ${input.z}, ${input.w})`; + } else if (input instanceof Color) { + return `new Color(${input.r}, ${input.g}, ${input.b})`; + } else if (input instanceof Texture) { + return `new Texture()`; + } + return `${input}`; +}; + +const evaluateNode = (node: DataNode) => { + if (node.type === 'number') { + return parseFloat(node.value); + } + + if (node.type === 'vector2') { + return new Vector2(parseFloat(node.value[0]), parseFloat(node.value[1])); + } else if (node.type === 'vector3') { + return new Vector3( + parseFloat(node.value[0]), + parseFloat(node.value[1]), + parseFloat(node.value[2]) + ); + } else if (node.type === 'vector4') { + return new Vector4( + parseFloat(node.value[0]), + parseFloat(node.value[1]), + parseFloat(node.value[2]), + parseFloat(node.value[3]) + ); + } else if (node.type === 'rgb') { + return new Color( + parseFloat(node.value[0]), + parseFloat(node.value[1]), + parseFloat(node.value[2]) + ); + } else if (node.type === 'rgba') { + return new Vector4( + parseFloat(node.value[0]), + parseFloat(node.value[1]), + parseFloat(node.value[2]), + parseFloat(node.value[3]) + ); + } else { + return node.value; + } +}; + export const threngine: Engine = { name: 'three', displayName: 'Three.js', @@ -692,123 +611,200 @@ export const threngine: Engine = { return ast; }, }, - [EngineNodeType.phong]: { - onBeforeCompile: async (graph, engineContext, node, sibling) => - cacher(engineContext, graph, node, sibling, () => - onBeforeCompileMegaShader( - engineContext, - new MeshPhongMaterial({ - // @ts-ignore - isMeshPhongMaterial: true, - ...threeMaterialProperties(graph, node, sibling), - }) - ) - ), - manipulateAst: megaShaderMainpulateAst, - }, - [EngineNodeType.physical]: { - onBeforeCompile: async (graph, engineContext, node, sibling) => - cacher(engineContext, graph, node, sibling, () => - onBeforeCompileMegaShader( - engineContext, - new MeshPhysicalMaterial({ - // These properties are copied onto the runtime RawShaderMaterial. - // These exist on the MeshPhysicalMaterial but only in the - // prototype. We have to hard code them for Object.keys() to work - ...node.config.hardCodedProperties, - ...threeMaterialProperties(graph, node, sibling), - }) - ) - ), - manipulateAst: megaShaderMainpulateAst, - }, - [EngineNodeType.toon]: { - onBeforeCompile: async (graph, engineContext, node, sibling) => - cacher(engineContext, graph, node, sibling, () => - onBeforeCompileMegaShader( - engineContext, - new MeshToonMaterial({ - gradientMap: new Texture(), - // @ts-ignore - isMeshToonMaterial: true, - ...threeMaterialProperties(graph, node, sibling), - }) - ) - ), - manipulateAst: megaShaderMainpulateAst, - }, + // Three engine nodes no longer need onBeforeCompile or manipulateAst + // since we use onBeforeCompile on the material itself (Step 5) + [EngineNodeType.phong]: {}, + [EngineNodeType.physical]: {}, + [EngineNodeType.toon]: {}, }, }; +// Attributes that Three.js's WebGLProgram always declares in its vertex prefix +const THREE_BUILTIN_ATTRIBUTES = new Set([ + 'position', 'normal', 'uv', 'uv2', 'tangent', 'color', + 'skinIndex', 'skinWeight', +]); + +// Varyings that Three.js declares in its shader chunks (conditionally) +const THREE_BUILTIN_VARYINGS = new Set([ + 'vUv', 'vUv2', 'vViewPosition', 'vNormal', 'vColor', 'vColor2', +]); + +const lineDeclaresAny = (line: string, names: Set): boolean => { + const ids = line.match(/\b[a-zA-Z_]\w*\b/g) ?? []; + return ids.some((id) => names.has(id)); +}; + +const extractUniformDeclarations = (glsl: string): string => + glsl + .split('\n') + .filter((l) => l.trimStart().startsWith('uniform ')) + .join('\n'); + +const stripUniformDeclarations = (glsl: string): string => + glsl + .split('\n') + .filter((l) => !l.trimStart().startsWith('uniform ')) + .join('\n'); + +// Strip #version and precision lines that Three.js handles automatically +const stripGlslPreamble = (glsl: string): string => + glsl + .replace(/^#version \d+ es\s*\n?/m, '') + .replace(/^\s*precision\s+\w+p\s+\w+;\s*\n?/gm, ''); + +// Strip `in`/`out` declarations for things Three.js owns: +// - `in` attributes (position, normal, uv…) are always in WebGLProgram's prefix +// - `in`/`out` varyings (vUv, vNormal…) are declared by Three.js's shader chunks +// when the corresponding #define (USE_UV etc.) is active. We force those defines +// in createMaterial so Three.js's chunks declare and populate them; if we also +// declare them from user code we get redefinition errors. +const stripThreeBuiltinDeclarations = (glsl: string): string => + glsl + .split('\n') + .filter((line) => { + const t = line.trimStart(); + if (/^in\s+\w/.test(t)) { + if (lineDeclaresAny(t, THREE_BUILTIN_ATTRIBUTES)) return false; + if (lineDeclaresAny(t, THREE_BUILTIN_VARYINGS)) return false; + return true; + } + if (/^out\s+\w/.test(t)) { + if (lineDeclaresAny(t, THREE_BUILTIN_VARYINGS)) return false; + return true; + } + return true; + }) + .join('\n'); + export const createMaterial = ( - compileResult: CompileResult, - ctx: EngineContext -) => { - const { engineMaterial } = ctx.runtime as ThreeRuntime; - - const finalUniforms = { - // TODO: Get these from threngine - ...ShaderLib.phong.uniforms, - ...ShaderLib.toon.uniforms, - ...ShaderLib.physical.uniforms, + fragmentResult: string, + vertexResult: string, + compileResult: CompileGraphResult, + graph: Graph, + userUniforms: Record = {} +): Material => { + const { threeNode } = compileResult; + + // Seed the uniforms with standard Shaderfrog-managed values + const uniforms: Record = { time: { value: 0 }, - cameraPosition: { value: new Vector3(1.0) }, renderResolution: { value: new Vector2(1.0) }, + cameraPosition: { value: new Vector3(1.0) }, + ...userUniforms, }; - // Also the ThreeComponent's sceneConfig properties modify the material - const initialProperties = { - name: 'ShaderFrog Material', - lights: true, - uniforms: { - ...finalUniforms, - }, - // See https://github.com/mrdoob/three.js/pull/26809 - glslVersion: GLSL3, - vertexShader: compileResult?.vertexResult.replace('#version 300 es', ''), - fragmentShader: compileResult?.fragmentResult.replace( - '#version 300 es', - '' - ), + if (!threeNode) { + const mat = new ShaderMaterial({ + uniforms, + vertexShader: vertexResult.replace(/^#version \d+ es\s*\n?/m, ''), + fragmentShader: fragmentResult.replace(/^#version \d+ es\s*\n?/m, ''), + }); + (mat as any).uniforms = uniforms; + return mat; + } + + const matClasses: Partial> = { + [EngineNodeType.physical]: MeshPhysicalMaterial, + [EngineNodeType.phong]: MeshPhongMaterial, + [EngineNodeType.toon]: MeshToonMaterial, }; + const MatClass = matClasses[threeNode.nodeType] ?? MeshPhysicalMaterial; - const additionalProperties = Object.entries({ - ...engineMaterial, - }) - .filter( - ([property]) => - // Ignore three material "hidden" properties - property.charAt(0) !== '_' && - // Ignore uuid since it should probably be unique? - property !== 'uuid' && - // I'm not sure what three does with type under the hood, ignore it - property !== 'type' && - // "precision" adds a precision preprocessor line - property !== 'precision' && - // Ignore existing properties - !(property in initialProperties) && - // Ignore STANDARD and PHYSICAL defines to the top of the shader in - // WebGLProgram - // https://github.com/mrdoob/three.js/blob/e7042de7c1a2c70e38654a04b6fd97d9c978e781/src/renderers/webgl/WebGLProgram.js#L392 - // which occurs if we set isMeshPhysicalMaterial/isMeshStandardMaterial - property !== 'defines' - ) - .reduce( - (acc, [key, value]) => ({ - ...acc, - [key]: value, - }), - {} - ); + const mat = new MatClass(); + + // Set forcing properties so Three emits the needed #defines + const injectionMap = threeInjectionMaps[threeNode.nodeType]; + for (const filler of threeNode.connectedFillers) { + const fragPoint = injectionMap?.fragment?.[filler.fillerName]; + if (fragPoint?.forceProperty) { + (mat as any)[fragPoint.forceProperty] = new Texture(); + } + } - const material = new RawShaderMaterial(initialProperties); + // Expose uniforms as a property so callers can read/write them directly + (mat as any).uniforms = uniforms; - // This prevents a deluge of warnings from three on the constructor saying - // that each of these properties is not a property of the material - Object.entries(additionalProperties).forEach(([key, value]) => { - // @ts-ignore - material[key] = value; - }); + mat.onBeforeCompile = (shader) => { + // Force Three.js UV system when user code references vUv — this makes + // uv_pars_vertex declare `out vec2 vUv` and uv_vertex set it from the uv attribute. + if (vertexResult.includes('vUv') || fragmentResult.includes('vUv')) { + shader.vertexShader = '#define USE_UV\n' + shader.vertexShader; + shader.fragmentShader = '#define USE_UV\n' + shader.fragmentShader; + } + + // Prepare fragment user code (strip version/precision headers and Three.js built-in varyings) + const cleanFrag = stripThreeBuiltinDeclarations(stripGlslPreamble(fragmentResult)); + const fragUniformDecls = extractUniformDeclarations(cleanFrag); + const fragFunctions = stripUniformDeclarations(cleanFrag); + + // 1. Prepend user uniform declarations to fragment shader + if (fragUniformDecls.trim()) { + shader.fragmentShader = fragUniformDecls + '\n' + shader.fragmentShader; + } + + // 2. Inject user fragment functions before void main() + if (fragFunctions.trim()) { + shader.fragmentShader = shader.fragmentShader.replace( + 'void main() {', + fragFunctions + '\nvoid main() {' + ); + } + + // 3. Apply fragment injection points (expand + modify specific chunks) + for (const filler of threeNode.connectedFillers) { + const point = injectionMap?.fragment?.[filler.fillerName]; + if (!point) continue; + + const fromNode = graph.nodes.find((n) => n.id === filler.fromNodeId); + const callExpr = fromNode ? `${nodeName(fromNode)}()` : 'sfFragmentEntry()'; + + const chunkSource = ShaderChunk[point.chunk as keyof typeof ShaderChunk]; + if (!chunkSource) { + log(`Warning: ShaderChunk["${point.chunk}"] not found`); + continue; + } + + shader.fragmentShader = shader.fragmentShader.replace( + `#include <${point.chunk}>`, + chunkSource.replace(point.find, point.replace(callExpr)) + ); + } + + // 4. Prepare and inject vertex user code + const cleanVert = stripThreeBuiltinDeclarations(stripGlslPreamble(vertexResult)); + const vertFunctions = stripUniformDeclarations(cleanVert); + + if (vertFunctions.trim()) { + shader.vertexShader = shader.vertexShader.replace( + 'void main() {', + vertFunctions + '\nvoid main() {' + ); + } + + // 5. Apply vertex injection points + for (const filler of threeNode.connectedFillers) { + const point = injectionMap?.vertex?.[filler.fillerName]; + if (!point) continue; + + const fromNode = graph.nodes.find((n) => n.id === filler.fromNodeId); + const callExpr = fromNode ? `${nodeName(fromNode)}()` : 'sfVertexEntry()'; + + const chunkSource = ShaderChunk[point.chunk as keyof typeof ShaderChunk]; + if (!chunkSource) { + log(`Warning: ShaderChunk["${point.chunk}"] not found`); + continue; + } + + shader.vertexShader = shader.vertexShader.replace( + `#include <${point.chunk}>`, + chunkSource.replace(point.find, point.replace(callExpr)) + ); + } + + // 6. Merge user uniform values into Three's uniform object (same reference) + Object.assign(shader.uniforms, uniforms); + }; - return material; + return mat; };