Merge hands/cultmath-cycles-converters: Cycles shader-node semantics in CultMath, exact to unfused Cycles
Cut cultmath-tapes cycles-converters (spec r2, attempts h1-h3), held by Soul s3 (verdict cultmath-tapes:verdict:cut-cycles-converters.s3) under ruling cycles-kernel-reference-ruled. Main carried only docs since the branch baseb3bd8dd5, so the merged code is the Soul-verified head53f4c213. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
b136ad9bd4
33 changed files with 72167 additions and 18 deletions
|
|
@ -33,9 +33,20 @@ shader semantics.
|
|||
- `shaders/CultMath.hlsl`, a canonical HLSL mirror include for shader-side
|
||||
parity. HLSL already owns `float2`, `float3`, and `float4`; the include
|
||||
exposes `cultmath_*` functions for shared semantics HLSL does not provide.
|
||||
It includes `shaders/CultMath.Phacelle.hlsl` (MPL-2.0) and
|
||||
It includes `shaders/CultMath.Phacelle.hlsl` (MPL-2.0),
|
||||
`shaders/CultMath.Cycles.hlsl` (Apache-2.0) and
|
||||
`shaders/CultMath.Interval.hlsl` and `shaders/CultMath.Affine.hlsl` from the same
|
||||
directory, so ship all four.
|
||||
directory, so ship all five.
|
||||
- Cycles node semantics (Apache-2.0): `cycles_*` functions that compute what
|
||||
Blender's Cycles renderer computes for the Math node operations whose results
|
||||
differ from a plain intrinsic (`cycles_divide`, `cycles_power`, `cycles_modulo`,
|
||||
`cycles_wrap`, `cycles_pingpong`, `cycles_smooth_min`, ...), Map Range
|
||||
(`cycles_map_range_linear`, `_stepped`, `_smoothstep`, `_smootherstep`), Clamp
|
||||
(`cycles_clamp_minmax`, `cycles_clamp_range`), Mix (`cycles_mix_float`,
|
||||
`cycles_mix_vector` and every colour blend mode as `cycles_mix_<mode>`), RGB to BW,
|
||||
and `cycles_ramp_index`, where a Color Ramp reads its table. `cultmath_cycles_*` in
|
||||
HLSL and GLSL. Operands are finite floats, and the tests hold every function to
|
||||
the results of Blender 5.2.2's own CPU Cycles (`tools/cycles-oracle`).
|
||||
- interval arithmetic for culling empty space: `iv_*` functions
|
||||
over `float2(lo, hi)` (`iv_add`, `iv_mul`, `iv_exp`, `iv_smoothstep`, ...)
|
||||
and `iv_snoise_ball`/`iv_fbm_ball`, which bound `snoise` and `fbm_grad` over a
|
||||
|
|
@ -58,11 +69,14 @@ shader semantics.
|
|||
- `shaders/CultMath.glsl` (MIT), the same `cultmath_*` library in GLSL ES 3.00
|
||||
for WebGL2, generated from `CultMath.hlsl` (never edited by hand), and
|
||||
`shaders/CultMath.Phacelle.glsl` (MPL-2.0), generated from
|
||||
`CultMath.Phacelle.hlsl`, which holds `cultmath_phacelle` alone. GLSL has no
|
||||
`CultMath.Phacelle.hlsl`, which holds `cultmath_phacelle` alone, and
|
||||
`shaders/CultMath.Cycles.glsl` (Apache-2.0), generated from
|
||||
`CultMath.Cycles.hlsl`, which holds the `cultmath_cycles_*` functions. GLSL has no
|
||||
`#include`: a WebGL consumer concatenates `CultMath.glsl` after its own
|
||||
`#version 300 es` line and `precision highp float; precision highp int;`
|
||||
(the library declares no precision, and its hashes need 32-bit integers),
|
||||
then `CultMath.Phacelle.glsl` only if it calls `cultmath_phacelle`, then its
|
||||
then `CultMath.Phacelle.glsl` only if it calls `cultmath_phacelle` and
|
||||
`CultMath.Cycles.glsl` only if it calls a `cultmath_cycles_` function, then its
|
||||
own shader source.
|
||||
- `Voronoi.SampleTones`, a C# batch surface that calls the Rust
|
||||
`cultmath-core` native kernel when `cultmath_core` is available and falls back
|
||||
|
|
|
|||
|
|
@ -22,6 +22,35 @@ permutation and scale as `snoise(float3)`.
|
|||
- Upstream: <https://github.com/ashima/webgl-noise>
|
||||
- License: MIT.
|
||||
|
||||
## Cycles node semantics
|
||||
|
||||
`math.cycles_*` and `cultmath_cycles_*` port what Blender's Cycles renderer
|
||||
computes for the Math, Map Range, Clamp, Mix and RGB to BW nodes and for a Color
|
||||
Ramp lookup. Each function's comment names the Cycles file it follows, all from
|
||||
`intern/cycles` at the Blender `v5.2.2` tag: `kernel/svm/math_util.h`,
|
||||
`kernel/svm/map_range.h`, `kernel/svm/clamp.h`, `kernel/svm/mix.h`,
|
||||
`kernel/svm/color_util.h`, `kernel/svm/ramp.h`, `kernel/svm/convert.h`,
|
||||
`util/math_base.h`, `util/math_float3.h` and `util/color.h`. The code was
|
||||
changed: Cycles C++ became C# and HLSL, one function per node operation, with
|
||||
the nodes' socket defaults and clamp options left to the caller, and the modulo
|
||||
is the quotient form every CultMath target shares rather than `fmodf`. Nothing
|
||||
was read from Blender's GPL sources (`source/blender/gpu`, `source/blender/blenlib`).
|
||||
|
||||
- Copyright 2011-2022 Blender Foundation.
|
||||
- Upstream: <https://projects.blender.org/blender/blender> (`intern/cycles`).
|
||||
- License: Apache-2.0 (<https://www.apache.org/licenses/LICENSE-2.0>), which is
|
||||
per-file here: it covers `src/CultMath/math.Cycles.cs`,
|
||||
`shaders/CultMath.Cycles.hlsl` and the Unity copy of that include, and
|
||||
`shaders/CultMath.Cycles.glsl`, which is generated from that include. Each
|
||||
carries the Apache-2.0 header and this attribution. The rest of CultMath, the
|
||||
generated GLSL library included, is MIT or MPL-2.0 as above, so the
|
||||
`GameCult.Math` NuGet package and the `org.gamecult.cultmath` Unity package
|
||||
both declare `MIT AND MPL-2.0 AND Apache-2.0` and ship this file.
|
||||
- The test fixtures under `tests/CultMath.Tests/fixtures/cycles-oracle` are
|
||||
results of running Blender 5.2.2 through `tools/cycles-oracle/oracle.py`
|
||||
(GPL-3.0-or-later, a Blender Python script, shipped neither in the NuGet
|
||||
package nor in the Unity package).
|
||||
|
||||
## Phacelle Noise
|
||||
|
||||
`math.phacelle` and `cultmath_phacelle` follow Rune Skovbo Johansen's Phacelle
|
||||
|
|
|
|||
|
|
@ -86,7 +86,9 @@ $requiredTemplateFiles = @(
|
|||
"Shaders\CultMath.Interval.hlsl",
|
||||
"Shaders\CultMath.Interval.hlsl.meta",
|
||||
"Shaders\CultMath.Affine.hlsl",
|
||||
"Shaders\CultMath.Affine.hlsl.meta"
|
||||
"Shaders\CultMath.Affine.hlsl.meta",
|
||||
"Shaders\CultMath.Cycles.hlsl",
|
||||
"Shaders\CultMath.Cycles.hlsl.meta"
|
||||
)
|
||||
foreach ($relativePath in $requiredTemplateFiles) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $templateRoot $relativePath) -PathType Leaf)) {
|
||||
|
|
|
|||
363
packages/cultmath/shaders/CultMath.Cycles.glsl
Normal file
363
packages/cultmath/shaders/CultMath.Cycles.glsl
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
// Generated from shaders/CultMath.Cycles.hlsl by GlslLowering (packages/cultmath/tests/CultMath.Tests/GlslLowering.cs). Do not edit; regenerate with CULTMATH_WRITE_GLSL=1 dotnet test --filter GlslMirrorTests. Apache-2.0; requires CultMath.glsl before it.
|
||||
#ifndef CULTMATH_CYCLES_GLSL
|
||||
#define CULTMATH_CYCLES_GLSL
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
// Cycles node semantics: what Blender's Cycles renderer computes for the Math, Map Range, Clamp, Mix and
|
||||
// RGB to BW nodes and for a Color Ramp lookup, ported function by function from the Cycles kernel
|
||||
// (intern/cycles of the Blender v5.2.2 tag; SPDX-FileCopyrightText: 2011-2022 Blender Foundation, Apache-2.0):
|
||||
// kernel/svm/{math_util,map_range,clamp,mix,color_util,ramp,convert}.h, util/math_base.h, util/color.h and
|
||||
// util/math_float3.h. Each function's comment names its Cycles source. Modified by GameCult: Cycles C++
|
||||
// becomes HLSL, a function per node operation, and the node's socket defaults and clamp options are left to
|
||||
// the caller. This file is Apache-2.0 (section 4: it was changed); CultMath.hlsl, which includes it after its
|
||||
// own definitions, is MIT (see THIRD-PARTY-NOTICES.md). math.Cycles.cs mirrors it bit for bit.
|
||||
//
|
||||
// Operands are finite floats. NaN and infinite operands are outside the oracle's reach and the mirror's.
|
||||
|
||||
// FLT_EPSILON, the least epsilon a Compare node honours.
|
||||
const float CULTMATH_CYCLES_FLT_EPSILON = 1.1920929e-07;
|
||||
|
||||
// The scene-linear luminance coefficients Cycles reads from Blender 5.2.2's default OCIO config
|
||||
// (kernel/film linear_rgb_to_gray). The oracle fixture pins them with a case per primary.
|
||||
const float CULTMATH_CYCLES_RGB_TO_Y_R = 0.212639093;
|
||||
const float CULTMATH_CYCLES_RGB_TO_Y_G = 0.715169132;
|
||||
const float CULTMATH_CYCLES_RGB_TO_Y_B = 0.0721927434;
|
||||
|
||||
// ---- Math node (kernel/svm/math_util.h svm_math; util/math_base.h) ----
|
||||
|
||||
// safe_divide
|
||||
float cultmath_cycles_divide(float a, float b) { return b != 0.0 ? a / b : 0.0; }
|
||||
|
||||
// safe_powf with compatible_powf. A negative base takes an integral exponent's parity; a base below zero
|
||||
// with a fractional exponent is 0.
|
||||
float cultmath_cycles_power(float a, float b)
|
||||
{
|
||||
float whole = b >= 0.0 ? floor(b) : ceil(b);
|
||||
if (a < 0.0 && b != whole)
|
||||
return 0.0;
|
||||
if (b == 0.0)
|
||||
return 1.0;
|
||||
if (a == 0.0)
|
||||
return 0.0;
|
||||
if (a < 0.0)
|
||||
{
|
||||
float magnitude = pow(-a, b);
|
||||
// Odd exactly when the remainder is 1: an infinite exponent leaves NaN here and is even, as powf(a, +-inf) is never negative.
|
||||
return b - 2.0 * floor(0.5 * b) == 1.0 ? -magnitude : magnitude;
|
||||
}
|
||||
return pow(a, b);
|
||||
}
|
||||
|
||||
// safe_logf
|
||||
float cultmath_cycles_logarithm(float a, float b)
|
||||
{
|
||||
if (a <= 0.0 || b <= 0.0)
|
||||
return 0.0;
|
||||
float denominator = log(b);
|
||||
return denominator != 0.0 ? log(a) / denominator : 0.0;
|
||||
}
|
||||
|
||||
// safe_sqrtf
|
||||
float cultmath_cycles_sqrt(float a) { return sqrt(max(a, 0.0)); }
|
||||
|
||||
// inversesqrtf
|
||||
float cultmath_cycles_inverse_sqrt(float a) { return a > 0.0 ? 1.0 / sqrt(a) : 0.0; }
|
||||
|
||||
// safe_modulo is fmodf, the remainder with the dividend's sign, exactly. GLSL has no fmod, and a - b trunc(a / b)
|
||||
// is wrong where the quotient rounds up to an integer or the product rounds (3 mod 0.3 is 0.29999995, not 0).
|
||||
// So this subtracts the largest power-of-two multiple of the divisor not above the remainder until the
|
||||
// remainder is below the divisor: every step is an exact subtraction, as in fmod. The loops are bounded by the
|
||||
// binades a float spans.
|
||||
float cultmath_cycles_modulo(float a, float b)
|
||||
{
|
||||
float remainder = abs(a);
|
||||
float divisor = abs(b);
|
||||
for (int steps = 0; steps < 300 && remainder >= divisor && divisor > 0.0; steps++)
|
||||
{
|
||||
float multiple = divisor;
|
||||
for (int doublings = 0; doublings < 300 && multiple * 2.0 <= remainder; doublings++)
|
||||
multiple = multiple * 2.0;
|
||||
remainder = remainder - multiple;
|
||||
}
|
||||
return b != 0.0 ? (a < 0.0 ? -remainder : remainder) : 0.0;
|
||||
}
|
||||
|
||||
// safe_floored_modulo; a build that contracts it differs by a bound stated at cycles_floored_modulo in math.Cycles.cs and the README
|
||||
float cultmath_cycles_floored_modulo(float a, float b) { return b != 0.0 ? a - floor(a / b) * b : 0.0; }
|
||||
|
||||
// wrapf (note the argument order: value, max, min); contraction bound as for floored_modulo
|
||||
float cultmath_cycles_wrap(float value, float max_value, float min_value)
|
||||
{
|
||||
float range = max_value - min_value;
|
||||
return range != 0.0 ? value - (range * floor((value - min_value) / range)) : min_value;
|
||||
}
|
||||
|
||||
// floorf(safe_divide(a, b)) * b
|
||||
float cultmath_cycles_snap(float a, float b) { return floor(cultmath_cycles_divide(a, b)) * b; }
|
||||
|
||||
// pingpongf
|
||||
float cultmath_cycles_pingpong(float a, float b)
|
||||
{
|
||||
float scaled = (a - b) / (b * 2.0);
|
||||
return b != 0.0 ? abs((scaled - floor(scaled)) * b * 2.0 - b) : 0.0;
|
||||
}
|
||||
|
||||
// smoothminf
|
||||
float cultmath_cycles_smooth_min(float a, float b, float k)
|
||||
{
|
||||
float h = max(k - abs(a - b), 0.0) / k;
|
||||
return k != 0.0 ? min(a, b) - h * h * h * k * 0.16666667 : min(a, b);
|
||||
}
|
||||
|
||||
// -smoothminf(-a, -b, k)
|
||||
float cultmath_cycles_smooth_max(float a, float b, float k) { return -cultmath_cycles_smooth_min(-a, -b, k); }
|
||||
|
||||
// NODE_MATH_COMPARE
|
||||
float cultmath_cycles_compare(float a, float b, float epsilon)
|
||||
{
|
||||
return (a == b || abs(a - b) <= max(epsilon, CULTMATH_CYCLES_FLT_EPSILON)) ? 1.0 : 0.0;
|
||||
}
|
||||
|
||||
// NODE_MATH_ROUND
|
||||
float cultmath_cycles_round(float a) { return floor(a + 0.5); }
|
||||
|
||||
// NODE_MATH_TRUNC
|
||||
float cultmath_cycles_trunc(float a) { return a >= 0.0 ? floor(a) : ceil(a); }
|
||||
|
||||
// NODE_MATH_FRACTION
|
||||
float cultmath_cycles_fract(float a) { return a - floor(a); }
|
||||
|
||||
// compatible_signf
|
||||
float cultmath_cycles_sign(float a) { return a == 0.0 ? 0.0 : (a < 0.0 ? -1.0 : 1.0); }
|
||||
|
||||
// safe_asinf
|
||||
float cultmath_cycles_arcsine(float a) { return asin(clamp(a, -1.0, 1.0)); }
|
||||
|
||||
// safe_acosf
|
||||
float cultmath_cycles_arccosine(float a) { return acos(clamp(a, -1.0, 1.0)); }
|
||||
|
||||
// compatible_atan2 (a is y, b is x)
|
||||
float cultmath_cycles_arctan2(float a, float b) { return (b == 0.0 && a == 0.0) ? 0.0 : atan(a, b); }
|
||||
|
||||
// ---- Map Range node (kernel/svm/map_range.h svm_node_map_range) ----
|
||||
// A Map Range node's Clamp option is a Clamp node of type Range on its result (MapRangeNode::expand):
|
||||
// compose cultmath_cycles_clamp_range over these.
|
||||
|
||||
float cultmath_cycles_map_range_linear(float value, float from_min, float from_max, float to_min, float to_max)
|
||||
{
|
||||
float factor = (value - from_min) / (from_max - from_min);
|
||||
return from_max != from_min ? to_min + factor * (to_max - to_min) : 0.0;
|
||||
}
|
||||
|
||||
float cultmath_cycles_map_range_stepped(float value, float from_min, float from_max, float to_min, float to_max, float steps)
|
||||
{
|
||||
float factor = (value - from_min) / (from_max - from_min);
|
||||
factor = steps > 0.0 ? floor(factor * (steps + 1.0)) / steps : 0.0;
|
||||
return from_max != from_min ? to_min + factor * (to_max - to_min) : 0.0;
|
||||
}
|
||||
|
||||
// The value itself, not a normalized one, goes through smoothstep(from_min, from_max, .), flipped when
|
||||
// from_min > from_max (util/math_base.h smoothstep).
|
||||
float cultmath_cycles_map_range_smoothstep(float value, float from_min, float from_max, float to_min, float to_max)
|
||||
{
|
||||
float low = min(from_min, from_max);
|
||||
float high = max(from_min, from_max);
|
||||
float t = (value - low) / (high - low);
|
||||
float eased = value < low ? 0.0 : (value >= high ? 1.0 : (3.0 - 2.0 * t) * (t * t));
|
||||
float factor = from_min > from_max ? 1.0 - eased : eased;
|
||||
return from_max != from_min ? to_min + factor * (to_max - to_min) : 0.0;
|
||||
}
|
||||
|
||||
// smootherstep of map_range.h, flipped when from_min > from_max.
|
||||
float cultmath_cycles_map_range_smootherstep(float value, float from_min, float from_max, float to_min, float to_max)
|
||||
{
|
||||
float low = min(from_min, from_max);
|
||||
float high = max(from_min, from_max);
|
||||
float x = clamp(cultmath_cycles_divide(value - low, high - low), 0.0, 1.0);
|
||||
float eased = x * x * x * (x * (x * 6.0 - 15.0) + 10.0);
|
||||
float factor = from_min > from_max ? 1.0 - eased : eased;
|
||||
return from_max != from_min ? to_min + factor * (to_max - to_min) : 0.0;
|
||||
}
|
||||
|
||||
// ---- Clamp node (kernel/svm/clamp.h svm_node_clamp) ----
|
||||
|
||||
// Type Min Max
|
||||
float cultmath_cycles_clamp_minmax(float value, float min_value, float max_value) { return clamp(value, min_value, max_value); }
|
||||
|
||||
// Type Range: the bounds swap when min_value > max_value.
|
||||
float cultmath_cycles_clamp_range(float value, float min_value, float max_value)
|
||||
{
|
||||
return min_value > max_value ? clamp(value, max_value, min_value) : clamp(value, min_value, max_value);
|
||||
}
|
||||
|
||||
// ---- Mix node (kernel/svm/mix.h, color_util.h) ----
|
||||
// Factor clamping (svm_node_mix_color's saturatef) and result clamping (saturate) are the caller's.
|
||||
|
||||
// svm_node_mix_float
|
||||
float cultmath_cycles_mix_float(float t, float a, float b) { return a * (1.0 - t) + b * t; }
|
||||
|
||||
// svm_node_mix_vector
|
||||
vec3 cultmath_cycles_mix_vector(float t, vec3 a, vec3 b) { return a * (vec3(1.0, 1.0, 1.0) - t) + b * t; }
|
||||
|
||||
vec3 cultmath_cycles_mix_blend(float t, vec3 col1, vec3 col2) { return col1 + t * (col2 - col1); }
|
||||
|
||||
vec3 cultmath_cycles_mix_add(float t, vec3 col1, vec3 col2) { return col1 + t * ((col1 + col2) - col1); }
|
||||
|
||||
vec3 cultmath_cycles_mix_mul(float t, vec3 col1, vec3 col2) { return col1 + t * (col1 * col2 - col1); }
|
||||
|
||||
vec3 cultmath_cycles_mix_screen(float t, vec3 col1, vec3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
vec3 one = vec3(1.0, 1.0, 1.0);
|
||||
return one - (vec3(tm, tm, tm) + t * (one - col2)) * (one - col1);
|
||||
}
|
||||
|
||||
vec3 cultmath_cycles_mix_overlay(float t, vec3 col1, vec3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
return vec3(
|
||||
col1.x < 0.5 ? col1.x * (tm + 2.0 * t * col2.x) : 1.0 - (tm + 2.0 * t * (1.0 - col2.x)) * (1.0 - col1.x),
|
||||
col1.y < 0.5 ? col1.y * (tm + 2.0 * t * col2.y) : 1.0 - (tm + 2.0 * t * (1.0 - col2.y)) * (1.0 - col1.y),
|
||||
col1.z < 0.5 ? col1.z * (tm + 2.0 * t * col2.z) : 1.0 - (tm + 2.0 * t * (1.0 - col2.z)) * (1.0 - col1.z));
|
||||
}
|
||||
|
||||
vec3 cultmath_cycles_mix_sub(float t, vec3 col1, vec3 col2) { return col1 + t * ((col1 - col2) - col1); }
|
||||
|
||||
vec3 cultmath_cycles_mix_div(float t, vec3 col1, vec3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
return vec3(
|
||||
col2.x != 0.0 ? tm * col1.x + t * col1.x / col2.x : col1.x,
|
||||
col2.y != 0.0 ? tm * col1.y + t * col1.y / col2.y : col1.y,
|
||||
col2.z != 0.0 ? tm * col1.z + t * col1.z / col2.z : col1.z);
|
||||
}
|
||||
|
||||
vec3 cultmath_cycles_mix_diff(float t, vec3 col1, vec3 col2) { return col1 + t * (abs(col1 - col2) - col1); }
|
||||
|
||||
vec3 cultmath_cycles_mix_exclusion(float t, vec3 col1, vec3 col2)
|
||||
{
|
||||
return max(col1 + t * ((col1 + col2 - 2.0 * col1 * col2) - col1), vec3(0.0, 0.0, 0.0));
|
||||
}
|
||||
|
||||
vec3 cultmath_cycles_mix_dark(float t, vec3 col1, vec3 col2) { return col1 + t * (min(col1, col2) - col1); }
|
||||
|
||||
vec3 cultmath_cycles_mix_light(float t, vec3 col1, vec3 col2) { return col1 + t * (max(col1, col2) - col1); }
|
||||
|
||||
vec3 cultmath_cycles_mix_dodge(float t, vec3 col1, vec3 col2)
|
||||
{
|
||||
vec3 divisor = vec3(1.0, 1.0, 1.0) - t * col2;
|
||||
return vec3(
|
||||
col1.x != 0.0 ? (divisor.x <= 0.0 ? 1.0 : min(col1.x / divisor.x, 1.0)) : col1.x,
|
||||
col1.y != 0.0 ? (divisor.y <= 0.0 ? 1.0 : min(col1.y / divisor.y, 1.0)) : col1.y,
|
||||
col1.z != 0.0 ? (divisor.z <= 0.0 ? 1.0 : min(col1.z / divisor.z, 1.0)) : col1.z);
|
||||
}
|
||||
|
||||
vec3 cultmath_cycles_mix_burn(float t, vec3 col1, vec3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
vec3 divisor = tm + t * col2;
|
||||
vec3 burnt = vec3(1.0, 1.0, 1.0) - (vec3(1.0, 1.0, 1.0) - col1) / divisor;
|
||||
return vec3(
|
||||
divisor.x <= 0.0 ? 0.0 : clamp(burnt.x, 0.0, 1.0),
|
||||
divisor.y <= 0.0 ? 0.0 : clamp(burnt.y, 0.0, 1.0),
|
||||
divisor.z <= 0.0 ? 0.0 : clamp(burnt.z, 0.0, 1.0));
|
||||
}
|
||||
|
||||
// rgb_to_hsv of util/color.h
|
||||
vec3 cultmath_cycles_rgb_to_hsv(vec3 rgb)
|
||||
{
|
||||
float cmax = max(rgb.x, max(rgb.y, rgb.z));
|
||||
float cmin = min(rgb.x, min(rgb.y, rgb.z));
|
||||
float cdelta = cmax - cmin;
|
||||
float s = cmax != 0.0 ? cdelta / cmax : 0.0;
|
||||
vec3 c = (vec3(cmax, cmax, cmax) - rgb) / cdelta;
|
||||
float h = rgb.x == cmax ? c.z - c.y : (rgb.y == cmax ? 2.0 + c.x - c.z : 4.0 + c.y - c.x);
|
||||
h = h * 0.16666667; // Cycles' kernel is built with reciprocal math: h /= 6.0f computes h * (1 / 6.0f).
|
||||
h = h < 0.0 ? h + 1.0 : h;
|
||||
return vec3(s != 0.0 ? h : 0.0, s, cmax);
|
||||
}
|
||||
|
||||
// hsv_to_rgb of util/color.h
|
||||
vec3 cultmath_cycles_hsv_to_rgb(vec3 hsv)
|
||||
{
|
||||
float h = hsv.x == 1.0 ? 0.0 : hsv.x;
|
||||
h = h * 6.0;
|
||||
float i = floor(h);
|
||||
float f = h - i;
|
||||
float p = hsv.z * (1.0 - hsv.y);
|
||||
float q = hsv.z * (1.0 - (hsv.y * f));
|
||||
float t = hsv.z * (1.0 - (hsv.y * (1.0 - f)));
|
||||
vec3 rgb = i == 0.0 ? vec3(hsv.z, t, p) :
|
||||
(i == 1.0 ? vec3(q, hsv.z, p) :
|
||||
(i == 2.0 ? vec3(p, hsv.z, t) :
|
||||
(i == 3.0 ? vec3(p, q, hsv.z) :
|
||||
(i == 4.0 ? vec3(t, p, hsv.z) : vec3(hsv.z, p, q)))));
|
||||
return hsv.y != 0.0 ? rgb : vec3(hsv.z, hsv.z, hsv.z);
|
||||
}
|
||||
|
||||
vec3 cultmath_cycles_mix_hue(float t, vec3 col1, vec3 col2)
|
||||
{
|
||||
vec3 hsv2 = cultmath_cycles_rgb_to_hsv(col2);
|
||||
vec3 hsv = cultmath_cycles_rgb_to_hsv(col1);
|
||||
vec3 shifted = cultmath_cycles_hsv_to_rgb(vec3(hsv2.x, hsv.y, hsv.z));
|
||||
return hsv2.y != 0.0 ? col1 + t * (shifted - col1) : col1;
|
||||
}
|
||||
|
||||
vec3 cultmath_cycles_mix_sat(float t, vec3 col1, vec3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
vec3 hsv = cultmath_cycles_rgb_to_hsv(col1);
|
||||
vec3 hsv2 = cultmath_cycles_rgb_to_hsv(col2);
|
||||
return hsv.y != 0.0 ? cultmath_cycles_hsv_to_rgb(vec3(hsv.x, tm * hsv.y + t * hsv2.y, hsv.z)) : col1;
|
||||
}
|
||||
|
||||
vec3 cultmath_cycles_mix_val(float t, vec3 col1, vec3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
vec3 hsv = cultmath_cycles_rgb_to_hsv(col1);
|
||||
vec3 hsv2 = cultmath_cycles_rgb_to_hsv(col2);
|
||||
return cultmath_cycles_hsv_to_rgb(vec3(hsv.x, hsv.y, tm * hsv.z + t * hsv2.z));
|
||||
}
|
||||
|
||||
vec3 cultmath_cycles_mix_color(float t, vec3 col1, vec3 col2)
|
||||
{
|
||||
vec3 hsv2 = cultmath_cycles_rgb_to_hsv(col2);
|
||||
vec3 hsv = cultmath_cycles_rgb_to_hsv(col1);
|
||||
vec3 shifted = cultmath_cycles_hsv_to_rgb(vec3(hsv2.x, hsv2.y, hsv.z));
|
||||
return hsv2.y != 0.0 ? col1 + t * (shifted - col1) : col1;
|
||||
}
|
||||
|
||||
vec3 cultmath_cycles_mix_soft(float t, vec3 col1, vec3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
vec3 one = vec3(1.0, 1.0, 1.0);
|
||||
vec3 scr = one - (one - col2) * (one - col1);
|
||||
return tm * col1 + t * ((one - col1) * col2 * col1 + col1 * scr);
|
||||
}
|
||||
|
||||
vec3 cultmath_cycles_mix_linear(float t, vec3 col1, vec3 col2)
|
||||
{
|
||||
return col1 + t * (2.0 * col2 + vec3(-1.0, -1.0, -1.0));
|
||||
}
|
||||
|
||||
// ---- RGB to BW node (kernel/svm/convert.h NODE_CONVERT_CF) ----
|
||||
|
||||
float cultmath_cycles_rgb_to_bw(vec3 color)
|
||||
{
|
||||
return color.x * CULTMATH_CYCLES_RGB_TO_Y_R + color.y * CULTMATH_CYCLES_RGB_TO_Y_G + color.z * CULTMATH_CYCLES_RGB_TO_Y_B;
|
||||
}
|
||||
|
||||
// ---- Color Ramp lookup (kernel/svm/ramp.h rgb_ramp_lookup, without extrapolation) ----
|
||||
|
||||
// Where a ramp of `size` table entries is read at t: (index, weight), the lower entry and the weight of the
|
||||
// next one. The colour is table[index] when weight is 0 and (1 - weight) table[index] + weight table[index + 1]
|
||||
// otherwise. The ramp table itself is the caller's; Blender's holds 257 entries, the ramp sampled at i / 256.
|
||||
vec2 cultmath_cycles_ramp_index(float t, int size)
|
||||
{
|
||||
float f = (t > 0.0 ? (t < 1.0 ? t : 1.0) : 0.0) * float(size - 1);
|
||||
int index = clamp(int(f), 0, size - 1);
|
||||
float lower = float(index);
|
||||
return vec2(lower, f - lower);
|
||||
}
|
||||
|
||||
#endif
|
||||
363
packages/cultmath/shaders/CultMath.Cycles.hlsl
Normal file
363
packages/cultmath/shaders/CultMath.Cycles.hlsl
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
#ifndef CULTMATH_CYCLES_HLSL
|
||||
#define CULTMATH_CYCLES_HLSL
|
||||
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
// Cycles node semantics: what Blender's Cycles renderer computes for the Math, Map Range, Clamp, Mix and
|
||||
// RGB to BW nodes and for a Color Ramp lookup, ported function by function from the Cycles kernel
|
||||
// (intern/cycles of the Blender v5.2.2 tag; SPDX-FileCopyrightText: 2011-2022 Blender Foundation, Apache-2.0):
|
||||
// kernel/svm/{math_util,map_range,clamp,mix,color_util,ramp,convert}.h, util/math_base.h, util/color.h and
|
||||
// util/math_float3.h. Each function's comment names its Cycles source. Modified by GameCult: Cycles C++
|
||||
// becomes HLSL, a function per node operation, and the node's socket defaults and clamp options are left to
|
||||
// the caller. This file is Apache-2.0 (section 4: it was changed); CultMath.hlsl, which includes it after its
|
||||
// own definitions, is MIT (see THIRD-PARTY-NOTICES.md). math.Cycles.cs mirrors it bit for bit.
|
||||
//
|
||||
// Operands are finite floats. NaN and infinite operands are outside the oracle's reach and the mirror's.
|
||||
|
||||
// FLT_EPSILON, the least epsilon a Compare node honours.
|
||||
static const float CULTMATH_CYCLES_FLT_EPSILON = 1.1920929e-07;
|
||||
|
||||
// The scene-linear luminance coefficients Cycles reads from Blender 5.2.2's default OCIO config
|
||||
// (kernel/film linear_rgb_to_gray). The oracle fixture pins them with a case per primary.
|
||||
static const float CULTMATH_CYCLES_RGB_TO_Y_R = 0.212639093;
|
||||
static const float CULTMATH_CYCLES_RGB_TO_Y_G = 0.715169132;
|
||||
static const float CULTMATH_CYCLES_RGB_TO_Y_B = 0.0721927434;
|
||||
|
||||
// ---- Math node (kernel/svm/math_util.h svm_math; util/math_base.h) ----
|
||||
|
||||
// safe_divide
|
||||
float cultmath_cycles_divide(float a, float b) { return b != 0.0 ? a / b : 0.0; }
|
||||
|
||||
// safe_powf with compatible_powf. A negative base takes an integral exponent's parity; a base below zero
|
||||
// with a fractional exponent is 0.
|
||||
float cultmath_cycles_power(float a, float b)
|
||||
{
|
||||
float whole = b >= 0.0 ? floor(b) : ceil(b);
|
||||
if (a < 0.0 && b != whole)
|
||||
return 0.0;
|
||||
if (b == 0.0)
|
||||
return 1.0;
|
||||
if (a == 0.0)
|
||||
return 0.0;
|
||||
if (a < 0.0)
|
||||
{
|
||||
float magnitude = pow(-a, b);
|
||||
// Odd exactly when the remainder is 1: an infinite exponent leaves NaN here and is even, as powf(a, +-inf) is never negative.
|
||||
return b - 2.0 * floor(0.5 * b) == 1.0 ? -magnitude : magnitude;
|
||||
}
|
||||
return pow(a, b);
|
||||
}
|
||||
|
||||
// safe_logf
|
||||
float cultmath_cycles_logarithm(float a, float b)
|
||||
{
|
||||
if (a <= 0.0 || b <= 0.0)
|
||||
return 0.0;
|
||||
float denominator = log(b);
|
||||
return denominator != 0.0 ? log(a) / denominator : 0.0;
|
||||
}
|
||||
|
||||
// safe_sqrtf
|
||||
float cultmath_cycles_sqrt(float a) { return sqrt(max(a, 0.0)); }
|
||||
|
||||
// inversesqrtf
|
||||
float cultmath_cycles_inverse_sqrt(float a) { return a > 0.0 ? 1.0 / sqrt(a) : 0.0; }
|
||||
|
||||
// safe_modulo is fmodf, the remainder with the dividend's sign, exactly. GLSL has no fmod, and a - b trunc(a / b)
|
||||
// is wrong where the quotient rounds up to an integer or the product rounds (3 mod 0.3 is 0.29999995, not 0).
|
||||
// So this subtracts the largest power-of-two multiple of the divisor not above the remainder until the
|
||||
// remainder is below the divisor: every step is an exact subtraction, as in fmod. The loops are bounded by the
|
||||
// binades a float spans.
|
||||
float cultmath_cycles_modulo(float a, float b)
|
||||
{
|
||||
float remainder = abs(a);
|
||||
float divisor = abs(b);
|
||||
for (int steps = 0; steps < 300 && remainder >= divisor && divisor > 0.0; steps++)
|
||||
{
|
||||
float multiple = divisor;
|
||||
for (int doublings = 0; doublings < 300 && multiple * 2.0 <= remainder; doublings++)
|
||||
multiple = multiple * 2.0;
|
||||
remainder = remainder - multiple;
|
||||
}
|
||||
return b != 0.0 ? (a < 0.0 ? -remainder : remainder) : 0.0;
|
||||
}
|
||||
|
||||
// safe_floored_modulo; a build that contracts it differs by a bound stated at cycles_floored_modulo in math.Cycles.cs and the README
|
||||
float cultmath_cycles_floored_modulo(float a, float b) { return b != 0.0 ? a - floor(a / b) * b : 0.0; }
|
||||
|
||||
// wrapf (note the argument order: value, max, min); contraction bound as for floored_modulo
|
||||
float cultmath_cycles_wrap(float value, float max_value, float min_value)
|
||||
{
|
||||
float range = max_value - min_value;
|
||||
return range != 0.0 ? value - (range * floor((value - min_value) / range)) : min_value;
|
||||
}
|
||||
|
||||
// floorf(safe_divide(a, b)) * b
|
||||
float cultmath_cycles_snap(float a, float b) { return floor(cultmath_cycles_divide(a, b)) * b; }
|
||||
|
||||
// pingpongf
|
||||
float cultmath_cycles_pingpong(float a, float b)
|
||||
{
|
||||
float scaled = (a - b) / (b * 2.0);
|
||||
return b != 0.0 ? abs((scaled - floor(scaled)) * b * 2.0 - b) : 0.0;
|
||||
}
|
||||
|
||||
// smoothminf
|
||||
float cultmath_cycles_smooth_min(float a, float b, float k)
|
||||
{
|
||||
float h = max(k - abs(a - b), 0.0) / k;
|
||||
return k != 0.0 ? min(a, b) - h * h * h * k * 0.16666667 : min(a, b);
|
||||
}
|
||||
|
||||
// -smoothminf(-a, -b, k)
|
||||
float cultmath_cycles_smooth_max(float a, float b, float k) { return -cultmath_cycles_smooth_min(-a, -b, k); }
|
||||
|
||||
// NODE_MATH_COMPARE
|
||||
float cultmath_cycles_compare(float a, float b, float epsilon)
|
||||
{
|
||||
return (a == b || abs(a - b) <= max(epsilon, CULTMATH_CYCLES_FLT_EPSILON)) ? 1.0 : 0.0;
|
||||
}
|
||||
|
||||
// NODE_MATH_ROUND
|
||||
float cultmath_cycles_round(float a) { return floor(a + 0.5); }
|
||||
|
||||
// NODE_MATH_TRUNC
|
||||
float cultmath_cycles_trunc(float a) { return a >= 0.0 ? floor(a) : ceil(a); }
|
||||
|
||||
// NODE_MATH_FRACTION
|
||||
float cultmath_cycles_fract(float a) { return a - floor(a); }
|
||||
|
||||
// compatible_signf
|
||||
float cultmath_cycles_sign(float a) { return a == 0.0 ? 0.0 : (a < 0.0 ? -1.0 : 1.0); }
|
||||
|
||||
// safe_asinf
|
||||
float cultmath_cycles_arcsine(float a) { return asin(clamp(a, -1.0, 1.0)); }
|
||||
|
||||
// safe_acosf
|
||||
float cultmath_cycles_arccosine(float a) { return acos(clamp(a, -1.0, 1.0)); }
|
||||
|
||||
// compatible_atan2 (a is y, b is x)
|
||||
float cultmath_cycles_arctan2(float a, float b) { return (b == 0.0 && a == 0.0) ? 0.0 : atan2(a, b); }
|
||||
|
||||
// ---- Map Range node (kernel/svm/map_range.h svm_node_map_range) ----
|
||||
// A Map Range node's Clamp option is a Clamp node of type Range on its result (MapRangeNode::expand):
|
||||
// compose cultmath_cycles_clamp_range over these.
|
||||
|
||||
float cultmath_cycles_map_range_linear(float value, float from_min, float from_max, float to_min, float to_max)
|
||||
{
|
||||
float factor = (value - from_min) / (from_max - from_min);
|
||||
return from_max != from_min ? to_min + factor * (to_max - to_min) : 0.0;
|
||||
}
|
||||
|
||||
float cultmath_cycles_map_range_stepped(float value, float from_min, float from_max, float to_min, float to_max, float steps)
|
||||
{
|
||||
float factor = (value - from_min) / (from_max - from_min);
|
||||
factor = steps > 0.0 ? floor(factor * (steps + 1.0)) / steps : 0.0;
|
||||
return from_max != from_min ? to_min + factor * (to_max - to_min) : 0.0;
|
||||
}
|
||||
|
||||
// The value itself, not a normalized one, goes through smoothstep(from_min, from_max, .), flipped when
|
||||
// from_min > from_max (util/math_base.h smoothstep).
|
||||
float cultmath_cycles_map_range_smoothstep(float value, float from_min, float from_max, float to_min, float to_max)
|
||||
{
|
||||
float low = min(from_min, from_max);
|
||||
float high = max(from_min, from_max);
|
||||
float t = (value - low) / (high - low);
|
||||
float eased = value < low ? 0.0 : (value >= high ? 1.0 : (3.0 - 2.0 * t) * (t * t));
|
||||
float factor = from_min > from_max ? 1.0 - eased : eased;
|
||||
return from_max != from_min ? to_min + factor * (to_max - to_min) : 0.0;
|
||||
}
|
||||
|
||||
// smootherstep of map_range.h, flipped when from_min > from_max.
|
||||
float cultmath_cycles_map_range_smootherstep(float value, float from_min, float from_max, float to_min, float to_max)
|
||||
{
|
||||
float low = min(from_min, from_max);
|
||||
float high = max(from_min, from_max);
|
||||
float x = clamp(cultmath_cycles_divide(value - low, high - low), 0.0, 1.0);
|
||||
float eased = x * x * x * (x * (x * 6.0 - 15.0) + 10.0);
|
||||
float factor = from_min > from_max ? 1.0 - eased : eased;
|
||||
return from_max != from_min ? to_min + factor * (to_max - to_min) : 0.0;
|
||||
}
|
||||
|
||||
// ---- Clamp node (kernel/svm/clamp.h svm_node_clamp) ----
|
||||
|
||||
// Type Min Max
|
||||
float cultmath_cycles_clamp_minmax(float value, float min_value, float max_value) { return clamp(value, min_value, max_value); }
|
||||
|
||||
// Type Range: the bounds swap when min_value > max_value.
|
||||
float cultmath_cycles_clamp_range(float value, float min_value, float max_value)
|
||||
{
|
||||
return min_value > max_value ? clamp(value, max_value, min_value) : clamp(value, min_value, max_value);
|
||||
}
|
||||
|
||||
// ---- Mix node (kernel/svm/mix.h, color_util.h) ----
|
||||
// Factor clamping (svm_node_mix_color's saturatef) and result clamping (saturate) are the caller's.
|
||||
|
||||
// svm_node_mix_float
|
||||
float cultmath_cycles_mix_float(float t, float a, float b) { return a * (1.0 - t) + b * t; }
|
||||
|
||||
// svm_node_mix_vector
|
||||
float3 cultmath_cycles_mix_vector(float t, float3 a, float3 b) { return a * (float3(1.0, 1.0, 1.0) - t) + b * t; }
|
||||
|
||||
float3 cultmath_cycles_mix_blend(float t, float3 col1, float3 col2) { return col1 + t * (col2 - col1); }
|
||||
|
||||
float3 cultmath_cycles_mix_add(float t, float3 col1, float3 col2) { return col1 + t * ((col1 + col2) - col1); }
|
||||
|
||||
float3 cultmath_cycles_mix_mul(float t, float3 col1, float3 col2) { return col1 + t * (col1 * col2 - col1); }
|
||||
|
||||
float3 cultmath_cycles_mix_screen(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
float3 one = float3(1.0, 1.0, 1.0);
|
||||
return one - (float3(tm, tm, tm) + t * (one - col2)) * (one - col1);
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_overlay(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
return float3(
|
||||
col1.x < 0.5 ? col1.x * (tm + 2.0 * t * col2.x) : 1.0 - (tm + 2.0 * t * (1.0 - col2.x)) * (1.0 - col1.x),
|
||||
col1.y < 0.5 ? col1.y * (tm + 2.0 * t * col2.y) : 1.0 - (tm + 2.0 * t * (1.0 - col2.y)) * (1.0 - col1.y),
|
||||
col1.z < 0.5 ? col1.z * (tm + 2.0 * t * col2.z) : 1.0 - (tm + 2.0 * t * (1.0 - col2.z)) * (1.0 - col1.z));
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_sub(float t, float3 col1, float3 col2) { return col1 + t * ((col1 - col2) - col1); }
|
||||
|
||||
float3 cultmath_cycles_mix_div(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
return float3(
|
||||
col2.x != 0.0 ? tm * col1.x + t * col1.x / col2.x : col1.x,
|
||||
col2.y != 0.0 ? tm * col1.y + t * col1.y / col2.y : col1.y,
|
||||
col2.z != 0.0 ? tm * col1.z + t * col1.z / col2.z : col1.z);
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_diff(float t, float3 col1, float3 col2) { return col1 + t * (abs(col1 - col2) - col1); }
|
||||
|
||||
float3 cultmath_cycles_mix_exclusion(float t, float3 col1, float3 col2)
|
||||
{
|
||||
return max(col1 + t * ((col1 + col2 - 2.0 * col1 * col2) - col1), float3(0.0, 0.0, 0.0));
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_dark(float t, float3 col1, float3 col2) { return col1 + t * (min(col1, col2) - col1); }
|
||||
|
||||
float3 cultmath_cycles_mix_light(float t, float3 col1, float3 col2) { return col1 + t * (max(col1, col2) - col1); }
|
||||
|
||||
float3 cultmath_cycles_mix_dodge(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float3 divisor = float3(1.0, 1.0, 1.0) - t * col2;
|
||||
return float3(
|
||||
col1.x != 0.0 ? (divisor.x <= 0.0 ? 1.0 : min(col1.x / divisor.x, 1.0)) : col1.x,
|
||||
col1.y != 0.0 ? (divisor.y <= 0.0 ? 1.0 : min(col1.y / divisor.y, 1.0)) : col1.y,
|
||||
col1.z != 0.0 ? (divisor.z <= 0.0 ? 1.0 : min(col1.z / divisor.z, 1.0)) : col1.z);
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_burn(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
float3 divisor = tm + t * col2;
|
||||
float3 burnt = float3(1.0, 1.0, 1.0) - (float3(1.0, 1.0, 1.0) - col1) / divisor;
|
||||
return float3(
|
||||
divisor.x <= 0.0 ? 0.0 : clamp(burnt.x, 0.0, 1.0),
|
||||
divisor.y <= 0.0 ? 0.0 : clamp(burnt.y, 0.0, 1.0),
|
||||
divisor.z <= 0.0 ? 0.0 : clamp(burnt.z, 0.0, 1.0));
|
||||
}
|
||||
|
||||
// rgb_to_hsv of util/color.h
|
||||
float3 cultmath_cycles_rgb_to_hsv(float3 rgb)
|
||||
{
|
||||
float cmax = max(rgb.x, max(rgb.y, rgb.z));
|
||||
float cmin = min(rgb.x, min(rgb.y, rgb.z));
|
||||
float cdelta = cmax - cmin;
|
||||
float s = cmax != 0.0 ? cdelta / cmax : 0.0;
|
||||
float3 c = (float3(cmax, cmax, cmax) - rgb) / cdelta;
|
||||
float h = rgb.x == cmax ? c.z - c.y : (rgb.y == cmax ? 2.0 + c.x - c.z : 4.0 + c.y - c.x);
|
||||
h = h * 0.16666667; // Cycles' kernel is built with reciprocal math: h /= 6.0f computes h * (1 / 6.0f).
|
||||
h = h < 0.0 ? h + 1.0 : h;
|
||||
return float3(s != 0.0 ? h : 0.0, s, cmax);
|
||||
}
|
||||
|
||||
// hsv_to_rgb of util/color.h
|
||||
float3 cultmath_cycles_hsv_to_rgb(float3 hsv)
|
||||
{
|
||||
float h = hsv.x == 1.0 ? 0.0 : hsv.x;
|
||||
h = h * 6.0;
|
||||
float i = floor(h);
|
||||
float f = h - i;
|
||||
float p = hsv.z * (1.0 - hsv.y);
|
||||
float q = hsv.z * (1.0 - (hsv.y * f));
|
||||
float t = hsv.z * (1.0 - (hsv.y * (1.0 - f)));
|
||||
float3 rgb = i == 0.0 ? float3(hsv.z, t, p) :
|
||||
(i == 1.0 ? float3(q, hsv.z, p) :
|
||||
(i == 2.0 ? float3(p, hsv.z, t) :
|
||||
(i == 3.0 ? float3(p, q, hsv.z) :
|
||||
(i == 4.0 ? float3(t, p, hsv.z) : float3(hsv.z, p, q)))));
|
||||
return hsv.y != 0.0 ? rgb : float3(hsv.z, hsv.z, hsv.z);
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_hue(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float3 hsv2 = cultmath_cycles_rgb_to_hsv(col2);
|
||||
float3 hsv = cultmath_cycles_rgb_to_hsv(col1);
|
||||
float3 shifted = cultmath_cycles_hsv_to_rgb(float3(hsv2.x, hsv.y, hsv.z));
|
||||
return hsv2.y != 0.0 ? col1 + t * (shifted - col1) : col1;
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_sat(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
float3 hsv = cultmath_cycles_rgb_to_hsv(col1);
|
||||
float3 hsv2 = cultmath_cycles_rgb_to_hsv(col2);
|
||||
return hsv.y != 0.0 ? cultmath_cycles_hsv_to_rgb(float3(hsv.x, tm * hsv.y + t * hsv2.y, hsv.z)) : col1;
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_val(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
float3 hsv = cultmath_cycles_rgb_to_hsv(col1);
|
||||
float3 hsv2 = cultmath_cycles_rgb_to_hsv(col2);
|
||||
return cultmath_cycles_hsv_to_rgb(float3(hsv.x, hsv.y, tm * hsv.z + t * hsv2.z));
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_color(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float3 hsv2 = cultmath_cycles_rgb_to_hsv(col2);
|
||||
float3 hsv = cultmath_cycles_rgb_to_hsv(col1);
|
||||
float3 shifted = cultmath_cycles_hsv_to_rgb(float3(hsv2.x, hsv2.y, hsv.z));
|
||||
return hsv2.y != 0.0 ? col1 + t * (shifted - col1) : col1;
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_soft(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
float3 one = float3(1.0, 1.0, 1.0);
|
||||
float3 scr = one - (one - col2) * (one - col1);
|
||||
return tm * col1 + t * ((one - col1) * col2 * col1 + col1 * scr);
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_linear(float t, float3 col1, float3 col2)
|
||||
{
|
||||
return col1 + t * (2.0 * col2 + float3(-1.0, -1.0, -1.0));
|
||||
}
|
||||
|
||||
// ---- RGB to BW node (kernel/svm/convert.h NODE_CONVERT_CF) ----
|
||||
|
||||
float cultmath_cycles_rgb_to_bw(float3 color)
|
||||
{
|
||||
return color.x * CULTMATH_CYCLES_RGB_TO_Y_R + color.y * CULTMATH_CYCLES_RGB_TO_Y_G + color.z * CULTMATH_CYCLES_RGB_TO_Y_B;
|
||||
}
|
||||
|
||||
// ---- Color Ramp lookup (kernel/svm/ramp.h rgb_ramp_lookup, without extrapolation) ----
|
||||
|
||||
// Where a ramp of `size` table entries is read at t: (index, weight), the lower entry and the weight of the
|
||||
// next one. The colour is table[index] when weight is 0 and (1 - weight) table[index] + weight table[index + 1]
|
||||
// otherwise. The ramp table itself is the caller's; Blender's holds 257 entries, the ramp sampled at i / 256.
|
||||
float2 cultmath_cycles_ramp_index(float t, int size)
|
||||
{
|
||||
float f = (t > 0.0 ? (t < 1.0 ? t : 1.0) : 0.0) * (float)(size - 1);
|
||||
int index = clamp((int)f, 0, size - 1);
|
||||
float lower = (float)index;
|
||||
return float2(lower, f - lower);
|
||||
}
|
||||
|
||||
#endif
|
||||
7
packages/cultmath/shaders/CultMath.Cycles.hlsl.meta
Normal file
7
packages/cultmath/shaders/CultMath.Cycles.hlsl.meta
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
fileFormatVersion: 2
|
||||
guid: e7f15f6042b54d728ada5e82dee741b4
|
||||
ShaderIncludeImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
// Generated from shaders/CultMath.hlsl and CultMath.Interval.hlsl and CultMath.Affine.hlsl by GlslLowering (packages/cultmath/tests/CultMath.Tests/GlslLowering.cs). Do not edit; regenerate with CULTMATH_WRITE_GLSL=1 dotnet test --filter GlslMirrorTests. MIT. cultmath_phacelle is in CultMath.Phacelle.glsl, to be concatenated after this file by consumers that call it.
|
||||
// Generated from shaders/CultMath.hlsl and CultMath.Interval.hlsl and CultMath.Affine.hlsl by GlslLowering (packages/cultmath/tests/CultMath.Tests/GlslLowering.cs). Do not edit; regenerate with CULTMATH_WRITE_GLSL=1 dotnet test --filter GlslMirrorTests. MIT. cultmath_phacelle is in CultMath.Phacelle.glsl, to be concatenated after this file by consumers that call it. cultmath_cycles_divide, cultmath_cycles_power, cultmath_cycles_logarithm, cultmath_cycles_sqrt, cultmath_cycles_inverse_sqrt, cultmath_cycles_modulo, cultmath_cycles_floored_modulo, cultmath_cycles_wrap, cultmath_cycles_snap, cultmath_cycles_pingpong, cultmath_cycles_smooth_min, cultmath_cycles_smooth_max, cultmath_cycles_compare, cultmath_cycles_round, cultmath_cycles_trunc, cultmath_cycles_fract, cultmath_cycles_sign, cultmath_cycles_arcsine, cultmath_cycles_arccosine, cultmath_cycles_arctan2, cultmath_cycles_map_range_linear, cultmath_cycles_map_range_stepped, cultmath_cycles_map_range_smoothstep, cultmath_cycles_map_range_smootherstep, cultmath_cycles_clamp_minmax, cultmath_cycles_clamp_range, cultmath_cycles_mix_float, cultmath_cycles_mix_vector, cultmath_cycles_mix_blend, cultmath_cycles_mix_add, cultmath_cycles_mix_mul, cultmath_cycles_mix_screen, cultmath_cycles_mix_overlay, cultmath_cycles_mix_sub, cultmath_cycles_mix_div, cultmath_cycles_mix_diff, cultmath_cycles_mix_exclusion, cultmath_cycles_mix_dark, cultmath_cycles_mix_light, cultmath_cycles_mix_dodge, cultmath_cycles_mix_burn, cultmath_cycles_rgb_to_hsv, cultmath_cycles_hsv_to_rgb, cultmath_cycles_mix_hue, cultmath_cycles_mix_sat, cultmath_cycles_mix_val, cultmath_cycles_mix_color, cultmath_cycles_mix_soft, cultmath_cycles_mix_linear, cultmath_cycles_rgb_to_bw, cultmath_cycles_ramp_index is in CultMath.Cycles.glsl, to be concatenated after this file by consumers that call it.
|
||||
#ifndef CULTMATH_GLSL
|
||||
#define CULTMATH_GLSL
|
||||
// CultMath HLSL mirror.
|
||||
|
|
|
|||
|
|
@ -509,6 +509,7 @@ CultCellular cultmath_cellular(float3 p)
|
|||
|
||||
#include "CultMath.Phacelle.hlsl"
|
||||
#include "CultMath.Interval.hlsl"
|
||||
#include "CultMath.Cycles.hlsl"
|
||||
#include "CultMath.Affine.hlsl"
|
||||
|
||||
float cultmath_value_noise(float2 position)
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@
|
|||
<RootNamespace>CultMath</RootNamespace>
|
||||
<AssemblyName>CultMath</AssemblyName>
|
||||
<PackageId>GameCult.Math</PackageId>
|
||||
<!-- MIT overall; math.Phacelle.cs and CultPhasor.cs (and the two Phacelle HLSL files) are MPL-2.0. -->
|
||||
<PackageLicenseExpression>MIT AND MPL-2.0</PackageLicenseExpression>
|
||||
<!-- MIT overall; math.Phacelle.cs and CultPhasor.cs (and the two Phacelle HLSL files) are MPL-2.0; math.Cycles.cs and CultMath.Cycles.hlsl are Apache-2.0. -->
|
||||
<PackageLicenseExpression>MIT AND MPL-2.0 AND Apache-2.0</PackageLicenseExpression>
|
||||
<Version>0.4.1</Version>
|
||||
<Description>Clean-room C# math shaped for HLSL-style renderer code.</Description>
|
||||
<Authors>GameCult</Authors>
|
||||
|
|
@ -26,6 +26,7 @@
|
|||
<None Include="..\..\shaders\CultMath.Phacelle.hlsl" Pack="true" PackagePath="contentFiles\any\any\shaders\CultMath.Phacelle.hlsl" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Include="..\..\shaders\CultMath.Interval.hlsl" Pack="true" PackagePath="contentFiles\any\any\shaders\CultMath.Interval.hlsl" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Include="..\..\shaders\CultMath.Affine.hlsl" Pack="true" PackagePath="contentFiles\any\any\shaders\CultMath.Affine.hlsl" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Include="..\..\shaders\CultMath.Cycles.hlsl" Pack="true" PackagePath="contentFiles\any\any\shaders\CultMath.Cycles.hlsl" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Include="..\..\THIRD-PARTY-NOTICES.md" Pack="true" PackagePath="\" />
|
||||
</ItemGroup>
|
||||
|
||||
|
|
|
|||
397
packages/cultmath/src/CultMath/math.Cycles.cs
Normal file
397
packages/cultmath/src/CultMath/math.Cycles.cs
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
// Cycles node semantics: what Blender's Cycles renderer computes for the Math, Map Range, Clamp, Mix and
|
||||
// RGB to BW nodes and for a Color Ramp lookup, ported function by function from the Cycles kernel
|
||||
// (intern/cycles of the Blender v5.2.2 tag; SPDX-FileCopyrightText: 2011-2022 Blender Foundation, Apache-2.0):
|
||||
// kernel/svm/{math_util,map_range,clamp,mix,color_util,ramp,convert}.h, util/math_base.h, util/color.h and
|
||||
// util/math_float3.h. Each function's comment names its Cycles source. Modified by GameCult: Cycles C++
|
||||
// becomes C#, a function per node operation, and the node's socket defaults and clamp options are left to
|
||||
// the caller. This file is Apache-2.0 (section 4: it was changed); the rest of CultMath is MIT, except the
|
||||
// MPL-2.0 Phacelle files (see THIRD-PARTY-NOTICES.md). shaders/CultMath.Cycles.hlsl mirrors it bit for bit.
|
||||
//
|
||||
// Operands are finite floats. NaN and infinite operands are outside the oracle's reach and the mirror's.
|
||||
// CyclesOracleTests holds every function to the results of Blender 5.2.2's own CPU Cycles.
|
||||
|
||||
namespace CultMath;
|
||||
|
||||
public static partial class math
|
||||
{
|
||||
// FLT_EPSILON, the least epsilon a Compare node honours.
|
||||
private const float CYCLES_FLT_EPSILON = 1.1920929e-07f;
|
||||
|
||||
/// <summary>The scene-linear red luminance coefficient Cycles' RGB to BW node reads from Blender 5.2.2's default OCIO config.</summary>
|
||||
public const float CYCLES_RGB_TO_Y_R = 0.212639093f;
|
||||
|
||||
/// <summary>The scene-linear green luminance coefficient Cycles' RGB to BW node reads from Blender 5.2.2's default OCIO config.</summary>
|
||||
public const float CYCLES_RGB_TO_Y_G = 0.715169132f;
|
||||
|
||||
/// <summary>The scene-linear blue luminance coefficient Cycles' RGB to BW node reads from Blender 5.2.2's default OCIO config.</summary>
|
||||
public const float CYCLES_RGB_TO_Y_B = 0.0721927434f;
|
||||
|
||||
// ---- Math node (kernel/svm/math_util.h svm_math; util/math_base.h) ----
|
||||
|
||||
/// <summary>Math node Divide (safe_divide): a / b, or 0 when b is 0.</summary>
|
||||
public static float cycles_divide(float a, float b) => b != 0.0f ? a / b : 0.0f;
|
||||
|
||||
/// <summary>
|
||||
/// Math node Power (safe_powf with compatible_powf): 0 for a negative base with a fractional exponent,
|
||||
/// 1 for exponent 0, 0 for base 0. A negative base takes an integral exponent's parity.
|
||||
/// </summary>
|
||||
public static float cycles_power(float a, float b)
|
||||
{
|
||||
var whole = b >= 0.0f ? floor(b) : ceil(b);
|
||||
if (a < 0.0f && b != whole)
|
||||
return 0.0f;
|
||||
if (b == 0.0f)
|
||||
return 1.0f;
|
||||
if (a == 0.0f)
|
||||
return 0.0f;
|
||||
if (a < 0.0f)
|
||||
{
|
||||
var magnitude = pow(-a, b);
|
||||
// Odd exactly when the remainder is 1: an infinite exponent leaves NaN here and is even, as powf(a, +-inf) is never negative.
|
||||
return b - 2.0f * floor(0.5f * b) == 1.0f ? -magnitude : magnitude;
|
||||
}
|
||||
|
||||
return pow(a, b);
|
||||
}
|
||||
|
||||
/// <summary>Math node Logarithm (safe_logf): log(a) / log(b), or 0 when a or b is not positive or log(b) is 0.</summary>
|
||||
public static float cycles_logarithm(float a, float b)
|
||||
{
|
||||
if (a <= 0.0f || b <= 0.0f)
|
||||
return 0.0f;
|
||||
var denominator = log(b);
|
||||
return denominator != 0.0f ? log(a) / denominator : 0.0f;
|
||||
}
|
||||
|
||||
/// <summary>Math node Square Root (safe_sqrtf): the square root of max(a, 0).</summary>
|
||||
public static float cycles_sqrt(float a) => sqrt(max(a, 0.0f));
|
||||
|
||||
/// <summary>Math node Inverse Square Root (inversesqrtf): 1 / sqrt(a), or 0 when a is not positive.</summary>
|
||||
public static float cycles_inverse_sqrt(float a) => a > 0.0f ? 1.0f / sqrt(a) : 0.0f;
|
||||
|
||||
/// <summary>
|
||||
/// Math node Truncated Modulo (safe_modulo), the remainder with the dividend's sign, exactly as fmodf computes
|
||||
/// it, or 0 when b is 0. GLSL has no fmod, and a - b trunc(a / b) is wrong where the quotient rounds up to an
|
||||
/// integer or the product rounds (3 mod 0.3 is 0.29999995, not 0). So this subtracts the largest power-of-two
|
||||
/// multiple of the divisor not above the remainder until the remainder is below the divisor: every step is an
|
||||
/// exact subtraction, as in fmod. The loops are bounded by the binades a float spans.
|
||||
/// </summary>
|
||||
public static float cycles_modulo(float a, float b)
|
||||
{
|
||||
var remainder = abs(a);
|
||||
var divisor = abs(b);
|
||||
for (var steps = 0; steps < 300 && remainder >= divisor && divisor > 0.0f; steps++)
|
||||
{
|
||||
var multiple = divisor;
|
||||
for (var doublings = 0; doublings < 300 && multiple * 2.0f <= remainder; doublings++)
|
||||
multiple = multiple * 2.0f;
|
||||
remainder = remainder - multiple;
|
||||
}
|
||||
|
||||
return b != 0.0f ? (a < 0.0f ? -remainder : remainder) : 0.0f;
|
||||
}
|
||||
|
||||
/// <summary>Math node Floored Modulo (safe_floored_modulo): a - floor(a / b) b, or 0 when b is 0, with one IEEE single
|
||||
/// rounding per operation (the reference of ruling cycles-kernel-reference-ruled). A build that contracts
|
||||
/// a - floor(a / b) b into one fused operation differs by at most 2^-23 (|a / b| + 1) |b| (measured 0.91 of 2^-24 (|a| + |b|)
|
||||
/// on Blender 5.2.2's AVX2 kernel); a fast-math build may also move floor(a / b) by one near an integer quotient,
|
||||
/// so the result differs by a whole |b|. See tools/cycles-oracle/README.md.</summary>
|
||||
public static float cycles_floored_modulo(float a, float b) => b != 0.0f ? a - floor(a / b) * b : 0.0f;
|
||||
|
||||
/// <summary>Math node Wrap (wrapf; note the argument order): value wrapped into [min_value, max_value), or min_value for an empty range,
|
||||
/// with one IEEE single rounding per operation. A build that contracts value - range floor(...) into one fused operation differs by at
|
||||
/// most 2^-23 (|value| + |min_value| + |range|) (measured 0.77 of 2^-24 on the AVX2 kernel); a fast-math build may also move floor by one,
|
||||
/// a whole |range|. See tools/cycles-oracle/README.md.</summary>
|
||||
public static float cycles_wrap(float value, float max_value, float min_value)
|
||||
{
|
||||
var range = max_value - min_value;
|
||||
return range != 0.0f ? value - (range * floor((value - min_value) / range)) : min_value;
|
||||
}
|
||||
|
||||
/// <summary>Math node Snap: floor(safe_divide(a, b)) b.</summary>
|
||||
public static float cycles_snap(float a, float b) => floor(cycles_divide(a, b)) * b;
|
||||
|
||||
/// <summary>Math node Ping-Pong (pingpongf): a triangle wave of a with period 2 b, or 0 when b is 0.</summary>
|
||||
public static float cycles_pingpong(float a, float b)
|
||||
{
|
||||
var scaled = (a - b) / (b * 2.0f);
|
||||
return b != 0.0f ? abs((scaled - floor(scaled)) * b * 2.0f - b) : 0.0f;
|
||||
}
|
||||
|
||||
/// <summary>Math node Smooth Minimum (smoothminf): the polynomial smooth minimum of width k, or min(a, b) when k is 0.</summary>
|
||||
public static float cycles_smooth_min(float a, float b, float k)
|
||||
{
|
||||
var h = max(k - abs(a - b), 0.0f) / k;
|
||||
return k != 0.0f ? min(a, b) - h * h * h * k * 0.16666667f : min(a, b);
|
||||
}
|
||||
|
||||
/// <summary>Math node Smooth Maximum: -smoothminf(-a, -b, k).</summary>
|
||||
public static float cycles_smooth_max(float a, float b, float k) => -cycles_smooth_min(-a, -b, k);
|
||||
|
||||
/// <summary>Math node Compare: 1 when a equals b or they differ by at most max(epsilon, FLT_EPSILON), else 0.</summary>
|
||||
public static float cycles_compare(float a, float b, float epsilon) =>
|
||||
(a == b || abs(a - b) <= max(epsilon, CYCLES_FLT_EPSILON)) ? 1.0f : 0.0f;
|
||||
|
||||
/// <summary>Math node Round: floor(a + 0.5).</summary>
|
||||
public static float cycles_round(float a) => floor(a + 0.5f);
|
||||
|
||||
/// <summary>Math node Truncate: floor for a >= 0, ceil otherwise.</summary>
|
||||
public static float cycles_trunc(float a) => a >= 0.0f ? floor(a) : ceil(a);
|
||||
|
||||
/// <summary>Math node Fraction: a - floor(a).</summary>
|
||||
public static float cycles_fract(float a) => a - floor(a);
|
||||
|
||||
/// <summary>Math node Sign (compatible_signf): 0 for 0, -1 below, 1 above.</summary>
|
||||
public static float cycles_sign(float a) => a == 0.0f ? 0.0f : (a < 0.0f ? -1.0f : 1.0f);
|
||||
|
||||
/// <summary>Math node Arcsine (safe_asinf, util/math_base.h): asin of a clamped to [-1, 1].</summary>
|
||||
public static float cycles_arcsine(float a) => asin(clamp(a, -1.0f, 1.0f));
|
||||
|
||||
/// <summary>Math node Arccosine (safe_acosf, util/math_base.h): acos of a clamped to [-1, 1].</summary>
|
||||
public static float cycles_arccosine(float a) => acos(clamp(a, -1.0f, 1.0f));
|
||||
|
||||
/// <summary>Math node Arctan2 (compatible_atan2, util/math_base.h; a is y, b is x): 0 at the origin, else atan2(a, b).</summary>
|
||||
public static float cycles_arctan2(float a, float b) => (b == 0.0f && a == 0.0f) ? 0.0f : atan2(a, b);
|
||||
|
||||
// ---- Map Range node (kernel/svm/map_range.h svm_node_map_range) ----
|
||||
// A Map Range node's Clamp option is a Clamp node of type Range on its result (MapRangeNode::expand):
|
||||
// compose cycles_clamp_range over these.
|
||||
|
||||
/// <summary>Map Range, Linear: to_min + (value - from_min) / (from_max - from_min) (to_max - to_min), or 0 when from_max equals from_min.</summary>
|
||||
public static float cycles_map_range_linear(float value, float from_min, float from_max, float to_min, float to_max)
|
||||
{
|
||||
var factor = (value - from_min) / (from_max - from_min);
|
||||
return from_max != from_min ? to_min + factor * (to_max - to_min) : 0.0f;
|
||||
}
|
||||
|
||||
/// <summary>Map Range, Stepped: the linear factor quantized to `steps` steps (0 when steps is not positive), or 0 when from_max equals from_min.</summary>
|
||||
public static float cycles_map_range_stepped(float value, float from_min, float from_max, float to_min, float to_max, float steps)
|
||||
{
|
||||
var factor = (value - from_min) / (from_max - from_min);
|
||||
factor = steps > 0.0f ? floor(factor * (steps + 1.0f)) / steps : 0.0f;
|
||||
return from_max != from_min ? to_min + factor * (to_max - to_min) : 0.0f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map Range, Smooth Step: the value goes through smoothstep(from_min, from_max, .), flipped when from_min
|
||||
/// is above from_max (util/math_base.h smoothstep), or 0 when from_max equals from_min.
|
||||
/// </summary>
|
||||
public static float cycles_map_range_smoothstep(float value, float from_min, float from_max, float to_min, float to_max)
|
||||
{
|
||||
var low = min(from_min, from_max);
|
||||
var high = max(from_min, from_max);
|
||||
var t = (value - low) / (high - low);
|
||||
var eased = value < low ? 0.0f : (value >= high ? 1.0f : (3.0f - 2.0f * t) * (t * t));
|
||||
var factor = from_min > from_max ? 1.0f - eased : eased;
|
||||
return from_max != from_min ? to_min + factor * (to_max - to_min) : 0.0f;
|
||||
}
|
||||
|
||||
/// <summary>Map Range, Smoother Step (map_range.h smootherstep), flipped when from_min is above from_max, or 0 when from_max equals from_min.</summary>
|
||||
public static float cycles_map_range_smootherstep(float value, float from_min, float from_max, float to_min, float to_max)
|
||||
{
|
||||
var low = min(from_min, from_max);
|
||||
var high = max(from_min, from_max);
|
||||
var x = clamp(cycles_divide(value - low, high - low), 0.0f, 1.0f);
|
||||
var eased = x * x * x * (x * (x * 6.0f - 15.0f) + 10.0f);
|
||||
var factor = from_min > from_max ? 1.0f - eased : eased;
|
||||
return from_max != from_min ? to_min + factor * (to_max - to_min) : 0.0f;
|
||||
}
|
||||
|
||||
// ---- Clamp node (kernel/svm/clamp.h svm_node_clamp) ----
|
||||
|
||||
/// <summary>Clamp node, Min Max: clamp(value, min_value, max_value).</summary>
|
||||
public static float cycles_clamp_minmax(float value, float min_value, float max_value) => clamp(value, min_value, max_value);
|
||||
|
||||
/// <summary>Clamp node, Range: as Min Max, with the bounds swapped when min_value is above max_value.</summary>
|
||||
public static float cycles_clamp_range(float value, float min_value, float max_value) =>
|
||||
min_value > max_value ? clamp(value, max_value, min_value) : clamp(value, min_value, max_value);
|
||||
|
||||
// ---- Mix node (kernel/svm/mix.h, color_util.h) ----
|
||||
// Factor clamping (svm_node_mix_color's saturatef) and result clamping (saturate) are the caller's.
|
||||
|
||||
/// <summary>Mix node, Float (svm_node_mix_float): a (1 - t) + b t.</summary>
|
||||
public static float cycles_mix_float(float t, float a, float b) => a * (1.0f - t) + b * t;
|
||||
|
||||
/// <summary>Mix node, Vector, uniform factor (svm_node_mix_vector): a (1 - t) + b t per component.</summary>
|
||||
public static float3 cycles_mix_vector(float t, float3 a, float3 b) => a * (new float3(1.0f, 1.0f, 1.0f) - t) + b * t;
|
||||
|
||||
/// <summary>Mix node, Mix blend mode (svm_mix_blend).</summary>
|
||||
public static float3 cycles_mix_blend(float t, float3 col1, float3 col2) => col1 + t * (col2 - col1);
|
||||
|
||||
/// <summary>Mix node, Add blend mode (svm_mix_add).</summary>
|
||||
public static float3 cycles_mix_add(float t, float3 col1, float3 col2) => col1 + t * ((col1 + col2) - col1);
|
||||
|
||||
/// <summary>Mix node, Multiply blend mode (svm_mix_mul).</summary>
|
||||
public static float3 cycles_mix_mul(float t, float3 col1, float3 col2) => col1 + t * (col1 * col2 - col1);
|
||||
|
||||
/// <summary>Mix node, Screen blend mode (svm_mix_screen).</summary>
|
||||
public static float3 cycles_mix_screen(float t, float3 col1, float3 col2)
|
||||
{
|
||||
var tm = 1.0f - t;
|
||||
var one = new float3(1.0f, 1.0f, 1.0f);
|
||||
return one - (new float3(tm, tm, tm) + t * (one - col2)) * (one - col1);
|
||||
}
|
||||
|
||||
/// <summary>Mix node, Overlay blend mode (svm_mix_overlay).</summary>
|
||||
public static float3 cycles_mix_overlay(float t, float3 col1, float3 col2)
|
||||
{
|
||||
var tm = 1.0f - t;
|
||||
return new float3(
|
||||
col1.x < 0.5f ? col1.x * (tm + 2.0f * t * col2.x) : 1.0f - (tm + 2.0f * t * (1.0f - col2.x)) * (1.0f - col1.x),
|
||||
col1.y < 0.5f ? col1.y * (tm + 2.0f * t * col2.y) : 1.0f - (tm + 2.0f * t * (1.0f - col2.y)) * (1.0f - col1.y),
|
||||
col1.z < 0.5f ? col1.z * (tm + 2.0f * t * col2.z) : 1.0f - (tm + 2.0f * t * (1.0f - col2.z)) * (1.0f - col1.z));
|
||||
}
|
||||
|
||||
/// <summary>Mix node, Subtract blend mode (svm_mix_sub).</summary>
|
||||
public static float3 cycles_mix_sub(float t, float3 col1, float3 col2) => col1 + t * ((col1 - col2) - col1);
|
||||
|
||||
/// <summary>Mix node, Divide blend mode (svm_mix_div): a channel whose col2 is 0 keeps col1.</summary>
|
||||
public static float3 cycles_mix_div(float t, float3 col1, float3 col2)
|
||||
{
|
||||
var tm = 1.0f - t;
|
||||
return new float3(
|
||||
col2.x != 0.0f ? tm * col1.x + t * col1.x / col2.x : col1.x,
|
||||
col2.y != 0.0f ? tm * col1.y + t * col1.y / col2.y : col1.y,
|
||||
col2.z != 0.0f ? tm * col1.z + t * col1.z / col2.z : col1.z);
|
||||
}
|
||||
|
||||
/// <summary>Mix node, Difference blend mode (svm_mix_diff).</summary>
|
||||
public static float3 cycles_mix_diff(float t, float3 col1, float3 col2) => col1 + t * (abs(col1 - col2) - col1);
|
||||
|
||||
/// <summary>Mix node, Exclusion blend mode (svm_mix_exclusion).</summary>
|
||||
public static float3 cycles_mix_exclusion(float t, float3 col1, float3 col2) =>
|
||||
max(col1 + t * ((col1 + col2 - 2.0f * col1 * col2) - col1), new float3(0.0f, 0.0f, 0.0f));
|
||||
|
||||
/// <summary>Mix node, Darken blend mode (svm_mix_dark).</summary>
|
||||
public static float3 cycles_mix_dark(float t, float3 col1, float3 col2) => col1 + t * (min(col1, col2) - col1);
|
||||
|
||||
/// <summary>Mix node, Lighten blend mode (svm_mix_light).</summary>
|
||||
public static float3 cycles_mix_light(float t, float3 col1, float3 col2) => col1 + t * (max(col1, col2) - col1);
|
||||
|
||||
/// <summary>Mix node, Dodge blend mode (svm_mix_dodge).</summary>
|
||||
public static float3 cycles_mix_dodge(float t, float3 col1, float3 col2)
|
||||
{
|
||||
var divisor = new float3(1.0f, 1.0f, 1.0f) - t * col2;
|
||||
return new float3(
|
||||
col1.x != 0.0f ? (divisor.x <= 0.0f ? 1.0f : min(col1.x / divisor.x, 1.0f)) : col1.x,
|
||||
col1.y != 0.0f ? (divisor.y <= 0.0f ? 1.0f : min(col1.y / divisor.y, 1.0f)) : col1.y,
|
||||
col1.z != 0.0f ? (divisor.z <= 0.0f ? 1.0f : min(col1.z / divisor.z, 1.0f)) : col1.z);
|
||||
}
|
||||
|
||||
/// <summary>Mix node, Burn blend mode (svm_mix_burn).</summary>
|
||||
public static float3 cycles_mix_burn(float t, float3 col1, float3 col2)
|
||||
{
|
||||
var tm = 1.0f - t;
|
||||
var divisor = tm + t * col2;
|
||||
var burnt = new float3(1.0f, 1.0f, 1.0f) - (new float3(1.0f, 1.0f, 1.0f) - col1) / divisor;
|
||||
return new float3(
|
||||
divisor.x <= 0.0f ? 0.0f : clamp(burnt.x, 0.0f, 1.0f),
|
||||
divisor.y <= 0.0f ? 0.0f : clamp(burnt.y, 0.0f, 1.0f),
|
||||
divisor.z <= 0.0f ? 0.0f : clamp(burnt.z, 0.0f, 1.0f));
|
||||
}
|
||||
|
||||
/// <summary>RGB to HSV as Cycles computes it (util/color.h rgb_to_hsv): hue in [0, 1), saturation, value.</summary>
|
||||
public static float3 cycles_rgb_to_hsv(float3 rgb)
|
||||
{
|
||||
var cmax = max(rgb.x, max(rgb.y, rgb.z));
|
||||
var cmin = min(rgb.x, min(rgb.y, rgb.z));
|
||||
var cdelta = cmax - cmin;
|
||||
var s = cmax != 0.0f ? cdelta / cmax : 0.0f;
|
||||
var c = (new float3(cmax, cmax, cmax) - rgb) / cdelta;
|
||||
var h = rgb.x == cmax ? c.z - c.y : (rgb.y == cmax ? 2.0f + c.x - c.z : 4.0f + c.y - c.x);
|
||||
h = h * 0.16666667f; // Cycles' kernel is built with reciprocal math: h /= 6.0f computes h * (1 / 6.0f).
|
||||
h = h < 0.0f ? h + 1.0f : h;
|
||||
return new float3(s != 0.0f ? h : 0.0f, s, cmax);
|
||||
}
|
||||
|
||||
/// <summary>HSV to RGB as Cycles computes it (util/color.h hsv_to_rgb).</summary>
|
||||
public static float3 cycles_hsv_to_rgb(float3 hsv)
|
||||
{
|
||||
var h = hsv.x == 1.0f ? 0.0f : hsv.x;
|
||||
h = h * 6.0f;
|
||||
var i = floor(h);
|
||||
var f = h - i;
|
||||
var p = hsv.z * (1.0f - hsv.y);
|
||||
var q = hsv.z * (1.0f - (hsv.y * f));
|
||||
var t = hsv.z * (1.0f - (hsv.y * (1.0f - f)));
|
||||
var rgb = i == 0.0f ? new float3(hsv.z, t, p) :
|
||||
(i == 1.0f ? new float3(q, hsv.z, p) :
|
||||
(i == 2.0f ? new float3(p, hsv.z, t) :
|
||||
(i == 3.0f ? new float3(p, q, hsv.z) :
|
||||
(i == 4.0f ? new float3(t, p, hsv.z) : new float3(hsv.z, p, q)))));
|
||||
return hsv.y != 0.0f ? rgb : new float3(hsv.z, hsv.z, hsv.z);
|
||||
}
|
||||
|
||||
/// <summary>Mix node, Hue blend mode (svm_mix_hue).</summary>
|
||||
public static float3 cycles_mix_hue(float t, float3 col1, float3 col2)
|
||||
{
|
||||
var hsv2 = cycles_rgb_to_hsv(col2);
|
||||
var hsv = cycles_rgb_to_hsv(col1);
|
||||
var shifted = cycles_hsv_to_rgb(new float3(hsv2.x, hsv.y, hsv.z));
|
||||
return hsv2.y != 0.0f ? col1 + t * (shifted - col1) : col1;
|
||||
}
|
||||
|
||||
/// <summary>Mix node, Saturation blend mode (svm_mix_sat).</summary>
|
||||
public static float3 cycles_mix_sat(float t, float3 col1, float3 col2)
|
||||
{
|
||||
var tm = 1.0f - t;
|
||||
var hsv = cycles_rgb_to_hsv(col1);
|
||||
var hsv2 = cycles_rgb_to_hsv(col2);
|
||||
return hsv.y != 0.0f ? cycles_hsv_to_rgb(new float3(hsv.x, tm * hsv.y + t * hsv2.y, hsv.z)) : col1;
|
||||
}
|
||||
|
||||
/// <summary>Mix node, Value blend mode (svm_mix_val).</summary>
|
||||
public static float3 cycles_mix_val(float t, float3 col1, float3 col2)
|
||||
{
|
||||
var tm = 1.0f - t;
|
||||
var hsv = cycles_rgb_to_hsv(col1);
|
||||
var hsv2 = cycles_rgb_to_hsv(col2);
|
||||
return cycles_hsv_to_rgb(new float3(hsv.x, hsv.y, tm * hsv.z + t * hsv2.z));
|
||||
}
|
||||
|
||||
/// <summary>Mix node, Color blend mode (svm_mix_color).</summary>
|
||||
public static float3 cycles_mix_color(float t, float3 col1, float3 col2)
|
||||
{
|
||||
var hsv2 = cycles_rgb_to_hsv(col2);
|
||||
var hsv = cycles_rgb_to_hsv(col1);
|
||||
var shifted = cycles_hsv_to_rgb(new float3(hsv2.x, hsv2.y, hsv.z));
|
||||
return hsv2.y != 0.0f ? col1 + t * (shifted - col1) : col1;
|
||||
}
|
||||
|
||||
/// <summary>Mix node, Soft Light blend mode (svm_mix_soft).</summary>
|
||||
public static float3 cycles_mix_soft(float t, float3 col1, float3 col2)
|
||||
{
|
||||
var tm = 1.0f - t;
|
||||
var one = new float3(1.0f, 1.0f, 1.0f);
|
||||
var scr = one - (one - col2) * (one - col1);
|
||||
return tm * col1 + t * ((one - col1) * col2 * col1 + col1 * scr);
|
||||
}
|
||||
|
||||
/// <summary>Mix node, Linear Light blend mode (svm_mix_linear).</summary>
|
||||
public static float3 cycles_mix_linear(float t, float3 col1, float3 col2) =>
|
||||
col1 + t * (2.0f * col2 + new float3(-1.0f, -1.0f, -1.0f));
|
||||
|
||||
// ---- RGB to BW node (kernel/svm/convert.h NODE_CONVERT_CF) ----
|
||||
|
||||
/// <summary>RGB to BW node: the scene-linear luminance of a colour.</summary>
|
||||
public static float cycles_rgb_to_bw(float3 color) =>
|
||||
color.x * CYCLES_RGB_TO_Y_R + color.y * CYCLES_RGB_TO_Y_G + color.z * CYCLES_RGB_TO_Y_B;
|
||||
|
||||
// ---- Color Ramp lookup (kernel/svm/ramp.h rgb_ramp_lookup, without extrapolation) ----
|
||||
|
||||
/// <summary>
|
||||
/// Where a Color Ramp of `size` table entries is read at t: (index, weight), the lower entry and the
|
||||
/// weight of the next one. The colour is table[index] when weight is 0 and
|
||||
/// (1 - weight) table[index] + weight table[index + 1] otherwise. The ramp table itself is the caller's;
|
||||
/// Blender's holds 257 entries, the ramp sampled at i / 256.
|
||||
/// </summary>
|
||||
public static float2 cycles_ramp_index(float t, int size)
|
||||
{
|
||||
var f = (t > 0.0f ? (t < 1.0f ? t : 1.0f) : 0.0f) * (float)(size - 1);
|
||||
var index = clamp((int)f, 0, size - 1);
|
||||
var lower = (float)index;
|
||||
return new float2(lower, f - lower);
|
||||
}
|
||||
}
|
||||
2
packages/cultmath/src/CultMath/math.Cycles.cs.meta
Normal file
2
packages/cultmath/src/CultMath/math.Cycles.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 0484788fead24b7cb0b8b373bf7e1f9d
|
||||
460
packages/cultmath/tests/CultMath.Tests/CyclesOracleTests.cs
Normal file
460
packages/cultmath/tests/CultMath.Tests/CyclesOracleTests.cs
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
using System.Reflection;
|
||||
using System.Text.Json.Nodes;
|
||||
using Xunit;
|
||||
|
||||
namespace CultMath.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// math.cycles_* and the shader mirror's cultmath_cycles_* (shaders/CultMath.Cycles.hlsl, compiled as C#) against
|
||||
/// the results of Blender 5.2.2's own Cycles on the CPU, the reference of ruling
|
||||
/// cultmath-tapes:ruling:cycles-kernel-reference-ruled: Cycles' formulas with one IEEE single rounding per
|
||||
/// operation. The fixtures under fixtures/cycles-oracle are written by
|
||||
/// tools/cycles-oracle/oracle.py (never by hand): each case is the operands and the node's baked results as
|
||||
/// float32 bit patterns. Every function is compared at every case within the tolerance named in
|
||||
/// <see cref="Tolerances"/>; a function not named there is held to 0 ulp. Signed zeros are not distinguished (-0
|
||||
/// equals 0), and NaN equals NaN. The fixtures come from Cycles' SSE4.2 CPU kernel, which does not fuse
|
||||
/// multiply-adds as C# does not (oracle.py checks this before it bakes); other builds are other tolerance classes
|
||||
/// (tools/cycles-oracle/README.md). Cycles' film writes 0 for an infinite or
|
||||
/// NaN result, so a case whose result here is not finite and whose fixture result is 0 holds nothing to compare.
|
||||
/// </summary>
|
||||
public sealed class CyclesOracleTests
|
||||
{
|
||||
// The ulp distance a function is allowed from the reference. Empty: the fixtures are the SSE4.2 kernel's results
|
||||
// and every function equals them, power and logarithm included (glibc's powf and logf, 0 ulp from MathF on Linux).
|
||||
// An entry needs a measured case on the reference named in it (a failing case's bits and ulp); other builds'
|
||||
// tolerances belong to the class table in tools/cycles-oracle/README.md, not here.
|
||||
private static readonly Dictionary<string, (int Ulp, string Measured)> Tolerances = new();
|
||||
|
||||
private const string CSharp = "C#";
|
||||
private const string Mirror = "HLSL mirror";
|
||||
|
||||
private static readonly Lazy<(object Shader, Type Type)> ShaderMirror = new(() =>
|
||||
{
|
||||
var (assembly, errors) = HlslSourceCompatibilityTests.CompileShaderMirror();
|
||||
Assert.True(assembly is not null, string.Join(Environment.NewLine, errors));
|
||||
var type = assembly!.GetType("CultMathHlsl.HlslShader")!;
|
||||
return (Activator.CreateInstance(type)!, type);
|
||||
});
|
||||
|
||||
/// <summary>One implementation of the Cycles functions: CultMath's C# or the HLSL text compiled as C#.</summary>
|
||||
private static object Call(string target, string function, params object[] arguments)
|
||||
{
|
||||
if (target == CSharp)
|
||||
return Method(function).Invoke(null, arguments)!;
|
||||
var (shader, type) = ShaderMirror.Value;
|
||||
var method = type.GetMethod("cultmath_" + function, BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?? throw new InvalidOperationException("no mirror function cultmath_" + function);
|
||||
return method.Invoke(shader, arguments)!;
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> Families() =>
|
||||
from family in new[] { "math", "map-range", "clamp", "mix", "rgb-to-bw" }
|
||||
from target in new[] { CSharp, Mirror }
|
||||
select new object[] { family, target };
|
||||
|
||||
public static IEnumerable<object[]> Targets() => new[] { CSharp, Mirror }.Select(target => new object[] { target });
|
||||
|
||||
private static string FixturePath(string family) =>
|
||||
Path.Combine(HlslSourceCompatibilityTests.FindCultMathRoot(), "tests", "CultMath.Tests", "fixtures", "cycles-oracle", family + ".json");
|
||||
|
||||
private static JsonObject Load(string family) => JsonNode.Parse(File.ReadAllText(FixturePath(family)))!.AsObject();
|
||||
|
||||
private static float Float(string token) => BitConverter.Int32BitsToSingle((int)Convert.ToUInt32(token, 16));
|
||||
|
||||
// A float32 bit pattern as an integer whose order and differences are the floats' order and ulps.
|
||||
private static int Ordered(float value)
|
||||
{
|
||||
var bits = BitConverter.SingleToInt32Bits(value);
|
||||
return bits < 0 ? int.MinValue - bits : bits;
|
||||
}
|
||||
|
||||
private static long Distance(float want, float have) =>
|
||||
want == have || (float.IsNaN(want) && float.IsNaN(have)) ? 0 : float.IsNaN(want) || float.IsNaN(have) ? long.MaxValue : Math.Abs((long)Ordered(want) - Ordered(have));
|
||||
|
||||
// The method takes floats and float3s only; each float3 is three consecutive tokens.
|
||||
private static object[] Arguments(MethodInfo method, string[] tokens)
|
||||
{
|
||||
var values = new List<object>();
|
||||
var next = 0;
|
||||
foreach (var parameter in method.GetParameters())
|
||||
{
|
||||
if (parameter.ParameterType == typeof(float))
|
||||
values.Add(Float(tokens[next++]));
|
||||
else if (parameter.ParameterType == typeof(float3))
|
||||
{
|
||||
values.Add(new float3(Float(tokens[next]), Float(tokens[next + 1]), Float(tokens[next + 2])));
|
||||
next += 3;
|
||||
}
|
||||
else
|
||||
throw new InvalidOperationException($"{method.Name}: parameter type {parameter.ParameterType.Name} is not a fixture operand");
|
||||
}
|
||||
|
||||
// The operands a function does not take (Map Range's Steps, for the three kinds that ignore it) are left unread.
|
||||
Assert.True(next <= tokens.Length, $"{method.Name}: {next} operands wanted, {tokens.Length} in the case");
|
||||
return values.ToArray();
|
||||
}
|
||||
|
||||
private static float[] Flatten(object result) => result switch
|
||||
{
|
||||
float f => new[] { f },
|
||||
float2 v => new[] { v.x, v.y },
|
||||
float3 v => new[] { v.x, v.y, v.z },
|
||||
_ => throw new InvalidOperationException(result.GetType().Name),
|
||||
};
|
||||
|
||||
private static MethodInfo Method(string name) =>
|
||||
typeof(math).GetMethod(name, BindingFlags.Public | BindingFlags.Static) ?? throw new InvalidOperationException("no CultMath function " + name);
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Families))]
|
||||
public void FamilyMatchesBlender(string family, string target)
|
||||
{
|
||||
var fixture = Load(family);
|
||||
Assert.Equal("5.2.2 LTS", (string?)fixture["blender"]);
|
||||
Assert.Equal("d13f752e3b9c", (string?)fixture["build_hash"]);
|
||||
var functions = fixture["functions"]!.AsArray();
|
||||
Assert.NotEmpty(functions);
|
||||
var failures = new List<string>();
|
||||
foreach (var entry in functions)
|
||||
{
|
||||
var name = (string)entry!["function"]!;
|
||||
var method = Method(name);
|
||||
var clamped = (string?)entry["then"] is not null;
|
||||
var allowed = Tolerances.TryGetValue(name, out var tolerance) ? tolerance.Ulp : 0;
|
||||
var worst = 0L;
|
||||
string? worstCase = null;
|
||||
var cases = entry["cases"]!.AsArray();
|
||||
Assert.True(cases.Count >= 1000, $"{name}: only {cases.Count} fixture cases");
|
||||
foreach (var line in cases)
|
||||
{
|
||||
var sides = ((string)line!).Split(" -> ");
|
||||
var operands = sides[0].Split(' ');
|
||||
var have = Flatten(Call(target, name, Arguments(method, operands)));
|
||||
if (clamped)
|
||||
{
|
||||
// A Map Range node's Clamp option is a Clamp node of type Range on its result.
|
||||
have = Flatten(Call(target, "cycles_clamp_range", have[0], Float(operands[3]), Float(operands[4])));
|
||||
}
|
||||
|
||||
var want = sides[1].Split(' ').Select(Float).ToArray();
|
||||
Assert.Equal(want.Length, have.Length);
|
||||
for (var k = 0; k < want.Length; k++)
|
||||
{
|
||||
if (!float.IsFinite(have[k]) && want[k] == 0.0f)
|
||||
continue;
|
||||
var distance = Distance(want[k], have[k]);
|
||||
if (distance > worst)
|
||||
{
|
||||
worst = distance;
|
||||
worstCase = $"{line} ({target} {BitConverter.SingleToInt32Bits(have[k]):x8})";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (worst > allowed)
|
||||
failures.Add($"{target} {name}{(clamped ? " with Clamp" : string.Empty)}: {worst} ulp from Blender, allowed {allowed}; worst case {worstCase}");
|
||||
}
|
||||
|
||||
Assert.True(failures.Count == 0, string.Join(Environment.NewLine, failures));
|
||||
}
|
||||
|
||||
private static JsonNode RampEntry(string interpolation) =>
|
||||
Load("ramp")["functions"]!.AsArray().Single(e => e!["node"]!.ToString() == "ShaderNodeValToRGB " + interpolation)!;
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Targets))]
|
||||
public void RampIndexNamesTheEntryBlenderReads(string target)
|
||||
{
|
||||
var entry = RampEntry("CONSTANT");
|
||||
var size = (int)entry["table_entries"]!;
|
||||
var stops = entry["stops"]!.AsArray().Select(stop => (Position: Float((string)stop![0]!), Value: Float((string)stop[1]!))).ToArray();
|
||||
|
||||
// A constant ramp's table: the value of the last stop at or before the entry's position.
|
||||
var table = Enumerable.Range(0, size).Select(i =>
|
||||
{
|
||||
var position = (double)i / (size - 1);
|
||||
var stop = stops.LastOrDefault(s => s.Position <= position);
|
||||
return stop == default ? stops[0].Value : stop.Value;
|
||||
}).ToArray();
|
||||
|
||||
var cases = entry["cases"]!.AsArray().Select(line => ((string)line!).Split(" -> ")).ToArray();
|
||||
Assert.True(table.Distinct().Count() > 30, "the constant ramp's table should name 32 entries apart");
|
||||
foreach (var line in cases)
|
||||
{
|
||||
var lookup = Flatten(Call(target, "cycles_ramp_index", Float(line[0]), size));
|
||||
var index = (int)lookup[0];
|
||||
Assert.InRange(index, 0, size - 1);
|
||||
Assert.True(Distance(Float(line[1]), table[index]) == 0, $"ramp t {line[0]}: index {index} reads {table[index]}, Blender {Float(line[1])}");
|
||||
}
|
||||
|
||||
// Every stop's value appears in the results at its own boundary, so the cases do cross the entries.
|
||||
Assert.True(cases.Select(line => line[1]).Distinct().Count() >= 32, "the cases never cross the stops");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Targets))]
|
||||
public void RampWeightBlendsTheEntryWithTheNextOne(string target)
|
||||
{
|
||||
// A linear ramp from 0 to 1: entry i holds i / (size - 1) to within the float rounding of Blender's table, and
|
||||
// a lookup at (index, weight) reads (index + weight) / (size - 1). A weight that is off by a fraction of a
|
||||
// percent, or an index that is off by one, moves the result by more than the bound.
|
||||
var entry = RampEntry("LINEAR");
|
||||
var size = (int)entry["table_entries"]!;
|
||||
var worst = 0.0;
|
||||
var fractional = 0;
|
||||
foreach (var line in entry["cases"]!.AsArray().Select(line => ((string)line!).Split(" -> ")))
|
||||
{
|
||||
var lookup = Flatten(Call(target, "cycles_ramp_index", Float(line[0]), size));
|
||||
Assert.InRange(lookup[1], 0.0f, 1.0f);
|
||||
fractional += lookup[1] > 0.0f ? 1 : 0;
|
||||
var expected = (lookup[0] + lookup[1]) / (size - 1);
|
||||
worst = Math.Max(worst, Math.Abs(expected - Float(line[1])));
|
||||
}
|
||||
|
||||
Assert.True(fractional > 500, "the cases hold too few interpolated lookups");
|
||||
Assert.True(worst <= Math.ScaleB(1.0, -22), $"a lookup is {worst} from (index + weight) / (size - 1), more than 2^-22");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Targets))]
|
||||
public void ResultsTheFilmCannotShowFollowTheCyclesSource(string target)
|
||||
{
|
||||
// Cycles' film writes 0 for an infinite or NaN result, so the fixtures cannot tell the guards that return 0
|
||||
// from the division that would return an infinity. These are the guards in svm_math (util/math_base.h):
|
||||
// safe_logf, safe_divide and inversesqrtf return 0 where the plain operation is not finite.
|
||||
float One(string function, params float[] arguments) => (float)Call(target, function, arguments.Cast<object>().ToArray());
|
||||
Assert.Equal(0.0f, One("cycles_logarithm", 0.0f, 2.0f));
|
||||
Assert.Equal(0.0f, One("cycles_logarithm", -1.0f, 2.0f));
|
||||
Assert.Equal(0.0f, One("cycles_logarithm", 2.0f, 0.0f));
|
||||
Assert.Equal(0.0f, One("cycles_logarithm", 2.0f, -1.0f));
|
||||
Assert.Equal(0.0f, One("cycles_divide", 1.0f, 0.0f));
|
||||
Assert.Equal(0.0f, One("cycles_inverse_sqrt", 0.0f));
|
||||
Assert.Equal(0.0f, One("cycles_modulo", 1.0f, 0.0f));
|
||||
Assert.Equal(0.0f, One("cycles_floored_modulo", 1.0f, 0.0f));
|
||||
}
|
||||
|
||||
private static float OperandOrInfinity(string token) => token == "inf" ? float.PositiveInfinity : token == "-inf" ? float.NegativeInfinity : Float(token);
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Targets))]
|
||||
public void InfiniteOperandsFollowBlender(string target)
|
||||
{
|
||||
// infinite.json: operands that are infinite, made inside Blender's kernel (the film writes 0 for an infinity), and
|
||||
// results read back through comparisons with 0 as "inf", "-inf" or "nan" where the film showed 0.
|
||||
var functions = Load("infinite")["functions"]!.AsArray();
|
||||
Assert.Equal(new[] { "cycles_power", "cycles_modulo", "cycles_map_range_smoothstep" }, functions.Select(f => (string)f!["function"]!).ToArray());
|
||||
var cases = 0;
|
||||
foreach (var entry in functions)
|
||||
{
|
||||
var name = (string)entry!["function"]!;
|
||||
foreach (var line in entry["cases"]!.AsArray())
|
||||
{
|
||||
var sides = ((string)line!).Split(" -> ");
|
||||
var operands = sides[0].Split(' ').Select(OperandOrInfinity).Cast<object>().ToArray();
|
||||
var have = (float)Call(target, name, operands);
|
||||
var ok = sides[1] switch
|
||||
{
|
||||
"nan" => float.IsNaN(have),
|
||||
"inf" => float.IsPositiveInfinity(have),
|
||||
"-inf" => float.IsNegativeInfinity(have),
|
||||
var token => Distance(Float(token), have) == 0,
|
||||
};
|
||||
Assert.True(ok, $"{target} {name} {sides[0]}: Blender {sides[1]}, here {have}");
|
||||
cases++;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(cases >= 45, $"{cases} infinite-operand cases");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Targets))]
|
||||
public void InfiniteOperandsFollowTheCyclesSource(string target)
|
||||
{
|
||||
// Blender 5.2.2 (infinite.json rows named beside each pin). powf(a, +-inf) is never negative, so an infinite
|
||||
// exponent is even; modulo and smoothstep of an infinity are NaN where inf - inf or inf / inf forms.
|
||||
float One(string function, params float[] arguments) => (float)Call(target, function, arguments.Cast<object>().ToArray());
|
||||
const float inf = float.PositiveInfinity;
|
||||
Assert.Equal(0x3f800000, BitConverter.SingleToInt32Bits(One("cycles_power", -1.0f, inf)));
|
||||
Assert.Equal(0x3f800000, BitConverter.SingleToInt32Bits(One("cycles_power", -1.0f, -inf)));
|
||||
Assert.Equal(inf, One("cycles_power", -2.0f, inf));
|
||||
Assert.Equal(inf, One("cycles_power", -0.5f, -inf));
|
||||
Assert.Equal(0, BitConverter.SingleToInt32Bits(One("cycles_power", -2.0f, -inf)));
|
||||
Assert.Equal(-8.0f, One("cycles_power", -2.0f, 3.0f));
|
||||
Assert.Equal(0x3f800000, BitConverter.SingleToInt32Bits(One("cycles_power", -1.0f, 2.0f)));
|
||||
Assert.True(float.IsNaN(One("cycles_modulo", inf, 1.0f)));
|
||||
Assert.True(float.IsNaN(One("cycles_modulo", -inf, 3.0f)));
|
||||
Assert.True(float.IsNaN(One("cycles_modulo", inf, 0.1f)));
|
||||
Assert.True(float.IsNaN(One("cycles_map_range_smoothstep", -inf, -inf, 1.0f, 2.0f, 3.0f)));
|
||||
Assert.True(float.IsNaN(One("cycles_map_range_smoothstep", -inf, 1.0f, -inf, 2.0f, 3.0f)));
|
||||
Assert.Equal(2.0f, One("cycles_map_range_smoothstep", -inf, 1.0f, 5.0f, 2.0f, 3.0f));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Targets))]
|
||||
public void InverseTrigonometryFollowsTheCyclesGuards(string target)
|
||||
{
|
||||
// util/math_base.h: safe_asinf and safe_acosf clamp to [-1, 1] before asinf and acosf; compatible_atan2 returns 0
|
||||
// at the origin (x == 0 && y == 0) and is atan2f everywhere else, so its guard is only the origin.
|
||||
float One(string function, params float[] arguments) => (float)Call(target, function, arguments.Cast<object>().ToArray());
|
||||
Assert.Equal(MathF.Asin(1.0f), One("cycles_arcsine", 2.0f));
|
||||
Assert.Equal(MathF.Asin(-1.0f), One("cycles_arcsine", -1.5f));
|
||||
Assert.Equal(MathF.Asin(1.0f), One("cycles_arcsine", 1.0000001f));
|
||||
Assert.Equal(0.0f, One("cycles_arccosine", 2.0f));
|
||||
Assert.Equal(MathF.Acos(-1.0f), One("cycles_arccosine", -2.0f));
|
||||
Assert.Equal(0.0f, One("cycles_arctan2", 0.0f, 0.0f));
|
||||
Assert.Equal(0.0f, One("cycles_arctan2", 0.0f, -0.0f));
|
||||
Assert.Equal(0.0f, One("cycles_arctan2", -0.0f, -0.0f));
|
||||
Assert.Equal(MathF.Atan2(-0.0f, -1.0f), One("cycles_arctan2", -0.0f, -1.0f));
|
||||
Assert.Equal(MathF.Atan2(0.0f, -1.0f), One("cycles_arctan2", 0.0f, -1.0f));
|
||||
Assert.Equal(MathF.Atan2(1.0f, 0.0f), One("cycles_arctan2", 1.0f, 0.0f));
|
||||
Assert.Equal(MathF.Atan2(-1.0f, 0.0f), One("cycles_arctan2", -1.0f, 0.0f));
|
||||
// The guard is the origin alone: a guard on either zero would zero these (the plain atan2 values are pi and pi/2).
|
||||
Assert.NotEqual(0.0f, One("cycles_arctan2", 0.0f, -1.0f));
|
||||
Assert.NotEqual(0.0f, One("cycles_arctan2", 1.0f, 0.0f));
|
||||
foreach (var value in new[] { One("cycles_arcsine", 2.0f), One("cycles_arccosine", -2.0f), One("cycles_arctan2", 0.0f, -0.0f), One("cycles_arctan2", 1.0f, 0.0f) })
|
||||
Assert.True(float.IsFinite(value));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Targets))]
|
||||
public void MapRangeSmoothStepDecidesTheTopEdgeBeforeDividing(string target)
|
||||
{
|
||||
// util/math_base.h smoothstep returns 1 for x >= edge1 before it forms t. With an overflowing range
|
||||
// (high - low = infinity) the division would be infinity / infinity, NaN.
|
||||
float Smooth(params float[] arguments) => (float)Call(target, "cycles_map_range_smoothstep", arguments.Cast<object>().ToArray());
|
||||
Assert.Equal(1.0f, Smooth(3.0e38f, -3.0e38f, 3.0e38f, 0.0f, 1.0f));
|
||||
Assert.Equal(0.0f, Smooth(3.0e38f, 3.0e38f, -3.0e38f, 0.0f, 1.0f));
|
||||
Assert.Equal(0.0f, Smooth(-3.0e38f, -3.0e38f, 3.0e38f, 0.0f, 1.0f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlooredModuloAndWrapUnderContractionStayWithinTheBound()
|
||||
{
|
||||
// Ruling cycles-kernel-reference-ruled: a build that contracts a - floor(a / b) b into one fused operation differs
|
||||
// from the reference by at most 2^-23 (|a| + |b|), and the wrap model by 2^-23 (|value| + |min| + |range|). The fused
|
||||
// model is MathF.FusedMultiplyAdd, test-only; no cycles_* function uses it.
|
||||
var bound = Math.ScaleB(1.0, -23);
|
||||
var worstModulo = 0.0;
|
||||
var worstWrap = 0.0;
|
||||
var seenPair = false;
|
||||
foreach (var entry in Load("math")["functions"]!.AsArray())
|
||||
{
|
||||
var name = (string)entry!["function"]!;
|
||||
if (name != "cycles_floored_modulo" && name != "cycles_wrap")
|
||||
continue;
|
||||
foreach (var line in entry["cases"]!.AsArray())
|
||||
{
|
||||
var o = ((string)line!).Split(" -> ")[0].Split(' ').Select(Float).ToArray();
|
||||
if (name == "cycles_floored_modulo")
|
||||
{
|
||||
float a = o[0], b = o[1];
|
||||
if (b == 0.0f)
|
||||
continue;
|
||||
var fused = MathF.FusedMultiplyAdd(-MathF.Floor(a / b), b, a);
|
||||
var scale = Math.Abs((double)a) + Math.Abs((double)b);
|
||||
var difference = Math.Abs((double)fused - math.cycles_floored_modulo(a, b));
|
||||
Assert.True(difference <= bound * scale, $"floored modulo ({line}): fused {fused} is {difference / scale} of |a| + |b| from the reference");
|
||||
worstModulo = Math.Max(worstModulo, difference / scale);
|
||||
if (a == 10000.0f && b == 0.1f)
|
||||
{
|
||||
seenPair = true;
|
||||
Assert.Equal(-1.4901161e-4f, fused);
|
||||
Assert.Equal(0.0f, math.cycles_floored_modulo(a, b));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
float value = o[0], max = o[1], min = o[2];
|
||||
var range = max - min;
|
||||
if (range == 0.0f)
|
||||
continue;
|
||||
var fused = MathF.FusedMultiplyAdd(-range, MathF.Floor((value - min) / range), value);
|
||||
var scale = Math.Abs((double)value) + Math.Abs((double)min) + Math.Abs((double)range);
|
||||
var difference = Math.Abs((double)fused - math.cycles_wrap(value, max, min));
|
||||
Assert.True(difference <= bound * scale, $"wrap ({line}): fused {fused} is {difference / scale} of |value| + |min| + |range| from the reference");
|
||||
worstWrap = Math.Max(worstWrap, difference / scale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(seenPair, "the fixture holds no (10000, 0.1) floored modulo case");
|
||||
// The cases reach the contraction regime: a bound of 2^-25 would fail.
|
||||
Assert.True(worstModulo > Math.ScaleB(1.0, -25), $"the worst floored modulo contraction is only {worstModulo} of |a| + |b|");
|
||||
Assert.True(worstWrap > Math.ScaleB(1.0, -25), $"the worst wrap contraction is only {worstWrap} of |value| + |min| + |range|");
|
||||
}
|
||||
|
||||
// The values oracle.py draws the specials of a function from (SPECIAL, plus the arcsine and arccosine edge rows); a row with any other operand holds a random draw.
|
||||
private static readonly HashSet<float> Alphabet = new[]
|
||||
{
|
||||
0.0, 1.0, -1.0, 0.5, -0.5, 2.0, -2.0, 3.0, -2.75, 0.1, -0.1, 7.5, -7.5, 1.0e-7, -1.0e-7, 100.0, -100.0, 1.0e4, 0.3, 0.7, 1.5, 1.0000001, -1.0000001,
|
||||
}.Select(v => (float)v).ToHashSet();
|
||||
|
||||
[Fact]
|
||||
public void OperandStreamsDifferPerFunction()
|
||||
{
|
||||
// oracle.py seeds every function, every blend mode's second colours and every ramp from its own name, so no two
|
||||
// share a stream of draws. At e48f9488 the seed was the name's first four bytes, and 'cycles_' shared them all.
|
||||
var functions = Load("math")["functions"]!.AsArray()
|
||||
.Select(e => (Name: (string)e!["function"]!, Rows: e["cases"]!.AsArray().Select(l => ((string)l!).Split(" -> ")[0]).ToArray()))
|
||||
.ToArray();
|
||||
Assert.True(functions.Length >= 17, "the math fixture lost functions");
|
||||
foreach (var f in functions)
|
||||
{
|
||||
var draws = f.Rows.Where(row => row.Split(' ').Any(token => !Alphabet.Contains(Float(token)))).ToHashSet();
|
||||
Assert.True(draws.Count >= 8, $"{f.Name} holds only {draws.Count} random-draw rows");
|
||||
foreach (var g in functions.Where(g => string.CompareOrdinal(f.Name, g.Name) < 0 && g.Rows[0].Split(' ').Length == f.Rows[0].Split(' ').Length))
|
||||
{
|
||||
var shared = g.Rows.Count(row => draws.Contains(row));
|
||||
Assert.True(shared == 0, $"{f.Name} and {g.Name} share {shared} random-draw rows");
|
||||
}
|
||||
}
|
||||
|
||||
var modes = Load("mix")["functions"]!.AsArray()
|
||||
.Where(e => ((string)e!["function"]!).StartsWith("cycles_mix_") && ((string)e["cases"]![0]!).Split(" -> ")[0].Split(' ').Length == 7)
|
||||
.Select(e => (Name: (string)e!["function"]!, Rows: e["cases"]!.AsArray().Select(l => ((string)l!).Split(" -> ")[0].Split(' ')).ToArray()))
|
||||
.ToArray();
|
||||
Assert.True(modes.Length >= 15, "the mix fixture lost blend modes");
|
||||
static string Multiset(IEnumerable<string[]> rows, int first) => string.Join("|", rows.Select(r => string.Join(" ", r.Skip(first).Take(3))).OrderBy(x => x, StringComparer.Ordinal));
|
||||
foreach (var mode in modes)
|
||||
Assert.True(Multiset(mode.Rows, 1) != Multiset(mode.Rows, 4), $"{mode.Name}: the second colours are the first colours");
|
||||
foreach (var f in modes)
|
||||
foreach (var g in modes.Where(g => string.CompareOrdinal(f.Name, g.Name) < 0))
|
||||
Assert.True(Multiset(f.Rows, 4) != Multiset(g.Rows, 4), $"{f.Name} and {g.Name} draw the same second colours");
|
||||
|
||||
var ramps = Load("ramp")["functions"]!.AsArray()
|
||||
.Select(e => e!["cases"]!.AsArray().Select(l => ((string)l!).Split(" -> ")[0]).ToArray()).ToArray();
|
||||
Assert.Equal(2, ramps.Length);
|
||||
var tail = ramps[0].TakeLast(100).ToHashSet();
|
||||
Assert.True(!ramps[1].TakeLast(100).Any(tail.Contains), "the ramps' random t tails overlap");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Targets))]
|
||||
public void HueSaturationAndValueOfThePrimariesFollowCycles(string target)
|
||||
{
|
||||
// util/color.h rgb_to_hsv: hue 0 for red, 1/3 for green, 2/3 for blue, saturation 1, value 1; a grey has
|
||||
// hue 0 and saturation 0. Red's hue is exactly 0 and not 1, which hsv_to_rgb would read the same.
|
||||
float[] Hsv(float r, float g, float b) => Flatten(Call(target, "cycles_rgb_to_hsv", new float3(r, g, b)));
|
||||
Assert.Equal(new[] { 0.0f, 1.0f, 1.0f }, Hsv(1.0f, 0.0f, 0.0f));
|
||||
Assert.Equal(new[] { 0.0f, 0.0f, 0.5f }, Hsv(0.5f, 0.5f, 0.5f));
|
||||
Assert.Equal(1.0f / 3.0f, Hsv(0.0f, 1.0f, 0.0f)[0], 6);
|
||||
Assert.Equal(2.0f / 3.0f, Hsv(0.0f, 0.0f, 1.0f)[0], 6);
|
||||
var back = Flatten(Call(target, "cycles_hsv_to_rgb", new float3(0.0f, 1.0f, 1.0f)));
|
||||
Assert.Equal(new[] { 1.0f, 0.0f, 0.0f }, back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RgbToBwCoefficientsAreTheOnesBlenderReads()
|
||||
{
|
||||
var entry = Load("rgb-to-bw")["functions"]!.AsArray().Single()!["cases"]!.AsArray().Select(line => ((string)line!).Split(" -> ")).ToArray();
|
||||
float Primary(int channel) => Float(entry.First(c =>
|
||||
{
|
||||
var operands = c[0].Split(' ').Select(Float).ToArray();
|
||||
return Enumerable.Range(0, 3).All(k => operands[k] == (k == channel ? 1.0f : 0.0f));
|
||||
})[1]);
|
||||
Assert.Equal(math.CYCLES_RGB_TO_Y_R, Primary(0));
|
||||
Assert.Equal(math.CYCLES_RGB_TO_Y_G, Primary(1));
|
||||
Assert.Equal(math.CYCLES_RGB_TO_Y_B, Primary(2));
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,8 @@ internal static class GlslLowering
|
|||
{
|
||||
// Phacelle is a port of MPL-2.0 code, and MPL-2.0 covers the files that carry it.
|
||||
["CultMath.Phacelle.hlsl"] = ("CultMath.Phacelle.glsl", "MPL-2.0"),
|
||||
// The Cycles node semantics are a port of Apache-2.0 code, and the licence travels with the file.
|
||||
["CultMath.Cycles.hlsl"] = ("CultMath.Cycles.glsl", "Apache-2.0"),
|
||||
};
|
||||
|
||||
private const string Regenerate =
|
||||
|
|
@ -84,13 +86,15 @@ internal static class GlslLowering
|
|||
source = LowerCasts(source);
|
||||
|
||||
// 6. Intrinsic renames: lerp -> mix, frac -> fract, rsqrt -> inversesqrt, asuint ->
|
||||
// floatBitsToUint, asfloat -> uintBitsToFloat, and saturate(x) -> clamp(x, 0.0, 1.0).
|
||||
// floatBitsToUint, asfloat -> uintBitsToFloat, atan2 -> atan (GLSL's two-argument arctangent),
|
||||
// and saturate(x) -> clamp(x, 0.0, 1.0).
|
||||
// Identifier boundaries keep cultmath_lerp and the rest of the cultmath_ names intact.
|
||||
source = Regex.Replace(source, @"\blerp\(", "mix(");
|
||||
source = Regex.Replace(source, @"\bfrac\(", "fract(");
|
||||
source = Regex.Replace(source, @"\brsqrt\(", "inversesqrt(");
|
||||
source = Regex.Replace(source, @"\basuint\(", "floatBitsToUint(");
|
||||
source = Regex.Replace(source, @"\basfloat\(", "uintBitsToFloat(");
|
||||
source = Regex.Replace(source, @"\batan2\(", "atan(");
|
||||
source = LowerSaturate(source);
|
||||
|
||||
// 7. Float literals pass through: HLSL's unsuffixed 0.5 and 1.0e30 are GLSL literals already.
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ public sealed class GlslMirrorTests
|
|||
|
||||
// Identifier boundaries: cultmath_lerp is a CultMath function, lerp( is HLSL.
|
||||
private static readonly Regex HlslOnly = new(
|
||||
@"\b(float|u?int|bool)[234]\b|\blerp\(|\bfrac\(|\bsaturate\(|\brsqrt\(|\basuint\(|\basfloat\(|static const|\((float|int|uint)\)|Texture2D|SamplerState|#include|precision (high|medium|low)p");
|
||||
@"\b(float|u?int|bool)[234]\b|\blerp\(|\bfrac\(|\bsaturate\(|\brsqrt\(|\basuint\(|\basfloat\(|\batan2\(|static const|\((float|int|uint)\)|Texture2D|SamplerState|#include|precision (high|medium|low)p");
|
||||
|
||||
[Fact]
|
||||
public void NoHlslOnlyTokensSurvive()
|
||||
|
|
@ -86,6 +86,12 @@ public sealed class GlslMirrorTests
|
|||
Assert.DoesNotContain(token, library);
|
||||
Assert.Contains(token, phacelle);
|
||||
}
|
||||
|
||||
// The Apache-2.0 code is in its own file too, and the MPL-2.0 file holds none of it.
|
||||
var cycles = lowered.Single(output => output.File == "CultMath.Cycles.glsl").Text;
|
||||
Assert.DoesNotContain("cultmath_cycles_divide(", library);
|
||||
Assert.DoesNotContain("cultmath_cycles_divide(", phacelle);
|
||||
Assert.Contains("float cultmath_cycles_divide(", cycles);
|
||||
}
|
||||
|
||||
// The two-file contract a consumer relies on when it concatenates CultMath.Phacelle.glsl after
|
||||
|
|
@ -106,6 +112,12 @@ public sealed class GlslMirrorTests
|
|||
Assert.Contains("cultmath_phacelle is in CultMath.Phacelle.glsl", library);
|
||||
Assert.Contains(" MPL-2.0;", phacelle);
|
||||
Assert.DoesNotContain("MIT", phacelle);
|
||||
|
||||
var cycles = lowered.Single(output => output.File == "CultMath.Cycles.glsl").Text.Split('\n')[0];
|
||||
Assert.Contains("cultmath_cycles_ramp_index is in CultMath.Cycles.glsl", library);
|
||||
Assert.Contains(" Apache-2.0;", cycles);
|
||||
Assert.DoesNotContain("MIT", cycles);
|
||||
Assert.DoesNotContain("MPL", cycles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -503,6 +515,12 @@ public sealed class GlslMirrorTests
|
|||
"Tightness: R0 + d <= GPU radius <= R0 + d + 2^-19 (R0 + |c0|_1 + spread). " +
|
||||
"Report the least slack of each side; only the bounds fail the check.";
|
||||
|
||||
// The Cycles node families' operands: values past [0, 1] on both sides, so the clamps, the extrapolating
|
||||
// factors and the divisions by a small number all show.
|
||||
private static float CyclesValue(System.Random r) => Uniform(r, -20.0f, 20.0f);
|
||||
private static float CyclesFactor(System.Random r) => Uniform(r, -0.5f, 1.5f);
|
||||
private static float3 CyclesColor(System.Random r) => new(Uniform(r, -0.25f, 1.25f), Uniform(r, -0.25f, 1.25f), Uniform(r, -0.25f, 1.25f));
|
||||
|
||||
// A Phacelle stripe wave vector: each component in [-6, 6], about one stripe per cell.
|
||||
private static float3 Side(System.Random r) => new(Uniform(r, -6.0f, 6.0f), Uniform(r, -6.0f, 6.0f), Uniform(r, -6.0f, 6.0f));
|
||||
|
||||
|
|
@ -511,7 +529,7 @@ public sealed class GlslMirrorTests
|
|||
// float families are ulp-bounded, with the bound measured on the device by the consumer, and
|
||||
// iv_frustum_ball also carries an enclosure check. phacelle is in CultMath.Phacelle.glsl, so its
|
||||
// evaluator concatenates that file after CultMath.glsl; the site, which does not vendor it, skips it.
|
||||
private static readonly Family[] Families =
|
||||
private static readonly Family[] Families = new Family[]
|
||||
{
|
||||
new("float cultmath_snoise(vec3)", "ulp-bounded", r => new object[] { Point3(r) }),
|
||||
new("float cultmath_snoise(vec2)", "ulp-bounded", r => new object[] { new float2(Uniform(r, -50.0f, 50.0f), Uniform(r, -50.0f, 50.0f)) }),
|
||||
|
|
@ -575,7 +593,44 @@ public sealed class GlslMirrorTests
|
|||
new("vec2 cultmath_iv_snoise_ball(vec2, float)", "ulp-bounded", r => new object[] { new float2(Uniform(r, -50.0f, 50.0f), Uniform(r, -50.0f, 50.0f)), Uniform(r, 0.001f, 2.0f) }),
|
||||
new("vec3 cultmath_af_snoise(vec3, vec3, float)", "ulp-bounded, reach 1e-6 to 1e-3", SmallReachSlice, SnoiseSmallForm, Verify: cases => AssertSnoiseForm(cases, allowanceDecides: true)),
|
||||
new("vec3 cultmath_af_fbm(vec3, vec3, float, int, float, float)", "ulp-bounded, reach 1e-6 to 1e-3", r => SmallReachSlice(r).Concat(new object[] { 4, 2.0f, 0.5f }).ToArray(), FbmSmallForm, Verify: cases => AssertFbmForm(cases, allowanceDecides: true)),
|
||||
};
|
||||
new("float cultmath_cycles_divide(float, float)", "ulp-bounded", r => new object[] { CyclesValue(r), CyclesValue(r) }),
|
||||
new("float cultmath_cycles_power(float, float)", "ulp-bounded, platform pow", r => new object[] { Uniform(r, -4.0f, 4.0f), r.Next(2) == 0 ? r.Next(-4, 5) : Uniform(r, -3.0f, 3.0f) }, Platform: Platform.Ulp),
|
||||
new("float cultmath_cycles_logarithm(float, float)", "ulp-bounded, platform log", r => new object[] { Uniform(r, 0.01f, 20.0f), Uniform(r, 0.01f, 20.0f) }, Platform: Platform.Ulp),
|
||||
new("float cultmath_cycles_sqrt(float)", "ulp-bounded", r => new object[] { CyclesValue(r) }),
|
||||
new("float cultmath_cycles_inverse_sqrt(float)", "ulp-bounded", r => new object[] { CyclesValue(r) }),
|
||||
new("float cultmath_cycles_modulo(float, float)", "ulp-bounded", r => new object[] { CyclesValue(r), CyclesValue(r) }),
|
||||
new("float cultmath_cycles_floored_modulo(float, float)", "ulp-bounded", r => new object[] { CyclesValue(r), CyclesValue(r) }),
|
||||
new("float cultmath_cycles_wrap(float, float, float)", "ulp-bounded", r => new object[] { CyclesValue(r), Uniform(r, -10.0f, 10.0f), Uniform(r, -10.0f, 10.0f) }),
|
||||
new("float cultmath_cycles_snap(float, float)", "ulp-bounded", r => new object[] { CyclesValue(r), CyclesValue(r) }),
|
||||
new("float cultmath_cycles_pingpong(float, float)", "ulp-bounded", r => new object[] { CyclesValue(r), CyclesValue(r) }),
|
||||
new("float cultmath_cycles_smooth_min(float, float, float)", "ulp-bounded", r => new object[] { CyclesValue(r), CyclesValue(r), Uniform(r, -5.0f, 5.0f) }),
|
||||
new("float cultmath_cycles_smooth_max(float, float, float)", "ulp-bounded", r => new object[] { CyclesValue(r), CyclesValue(r), Uniform(r, -5.0f, 5.0f) }),
|
||||
new("float cultmath_cycles_compare(float, float, float)", "ulp-bounded", r => { var a = CyclesValue(r); return new object[] { a, a + Uniform(r, -1.0f, 1.0f) * 1.0e-3f, Uniform(r, 0.0f, 2.0e-3f) }; }),
|
||||
new("float cultmath_cycles_round(float)", "ulp-bounded", r => new object[] { CyclesValue(r) }),
|
||||
new("float cultmath_cycles_trunc(float)", "ulp-bounded", r => new object[] { CyclesValue(r) }),
|
||||
new("float cultmath_cycles_fract(float)", "ulp-bounded", r => new object[] { CyclesValue(r) }),
|
||||
new("float cultmath_cycles_sign(float)", "ulp-bounded", r => new object[] { r.Next(4) == 0 ? 0.0f : CyclesValue(r) }),
|
||||
new("float cultmath_cycles_map_range_linear(float, float, float, float, float)", "ulp-bounded", r => new object[] { CyclesValue(r), CyclesValue(r), CyclesValue(r), CyclesValue(r), CyclesValue(r) }),
|
||||
new("float cultmath_cycles_map_range_stepped(float, float, float, float, float, float)", "ulp-bounded", r => new object[] { CyclesValue(r), CyclesValue(r), CyclesValue(r), CyclesValue(r), CyclesValue(r), Uniform(r, -1.0f, 8.0f) }),
|
||||
new("float cultmath_cycles_map_range_smoothstep(float, float, float, float, float)", "ulp-bounded", r => new object[] { CyclesValue(r), CyclesValue(r), CyclesValue(r), CyclesValue(r), CyclesValue(r) }),
|
||||
new("float cultmath_cycles_map_range_smootherstep(float, float, float, float, float)", "ulp-bounded", r => new object[] { CyclesValue(r), CyclesValue(r), CyclesValue(r), CyclesValue(r), CyclesValue(r) }),
|
||||
new("float cultmath_cycles_clamp_minmax(float, float, float)", "ulp-bounded", r => new object[] { CyclesValue(r), CyclesValue(r), CyclesValue(r) }),
|
||||
new("float cultmath_cycles_clamp_range(float, float, float)", "ulp-bounded", r => new object[] { CyclesValue(r), CyclesValue(r), CyclesValue(r) }),
|
||||
new("float cultmath_cycles_mix_float(float, float, float)", "ulp-bounded", r => new object[] { CyclesFactor(r), CyclesValue(r), CyclesValue(r) }),
|
||||
new("vec3 cultmath_cycles_mix_vector(float, vec3, vec3)", "ulp-bounded", r => new object[] { CyclesFactor(r), CyclesColor(r), CyclesColor(r) }),
|
||||
new("vec3 cultmath_cycles_rgb_to_hsv(vec3)", "ulp-bounded", r => new object[] { CyclesColor(r) }),
|
||||
new("vec3 cultmath_cycles_hsv_to_rgb(vec3)", "ulp-bounded", r => new object[] { new float3(r.NextSingle(), r.NextSingle(), Uniform(r, 0.0f, 2.0f)) }),
|
||||
new("float cultmath_cycles_rgb_to_bw(vec3)", "ulp-bounded", r => new object[] { CyclesColor(r) }),
|
||||
new("vec2 cultmath_cycles_ramp_index(float, int)", "ulp-bounded", r => new object[] { Uniform(r, -0.25f, 1.25f), 257 }),
|
||||
}.Concat(new[]
|
||||
{
|
||||
"blend", "add", "mul", "screen", "overlay", "sub", "div", "diff", "exclusion", "dark", "light", "dodge", "burn", "hue", "sat", "val", "color", "soft", "linear",
|
||||
}.Select(mode => new Family($"vec3 cultmath_cycles_mix_{mode}(float, vec3, vec3)", "ulp-bounded", r => new object[] { CyclesFactor(r), CyclesColor(r), CyclesColor(r) }))).Concat(new[]
|
||||
{
|
||||
new Family("float cultmath_cycles_arcsine(float)", "ulp-bounded, platform asin", r => new object[] { Uniform(r, -1.5f, 1.5f) }, Platform: Platform.Ulp),
|
||||
new Family("float cultmath_cycles_arccosine(float)", "ulp-bounded, platform acos", r => new object[] { Uniform(r, -1.5f, 1.5f) }, Platform: Platform.Ulp),
|
||||
new Family("float cultmath_cycles_arctan2(float, float)", "ulp-bounded, platform atan", r => new object[] { Uniform(r, -20.0f, 20.0f), Uniform(r, -20.0f, 20.0f) }, Platform: Platform.Ulp),
|
||||
}).ToArray();
|
||||
|
||||
// The fixture text, each case's arguments given by family and point index.
|
||||
private static string GoldenFixture(Func<int, int, object[]> arguments)
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ public sealed class HlslMirrorTests
|
|||
[InlineData("CultMath.Phacelle.hlsl")]
|
||||
[InlineData("CultMath.Interval.hlsl")]
|
||||
[InlineData("CultMath.Affine.hlsl")]
|
||||
[InlineData("CultMath.Cycles.hlsl")]
|
||||
public void UnityPackageShaderIsIdenticalToTheCanonicalShader(string name)
|
||||
{
|
||||
var root = GetCultMathRoot();
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ public sealed class HlslSourceCompatibilityTests
|
|||
}
|
||||
|
||||
Assert.True(mismatches.Count == 0, $"{mismatches.Count} mismatches:{Environment.NewLine}{string.Join(Environment.NewLine, mismatches.Take(12))}");
|
||||
Assert.Equal(67, compared.Count);
|
||||
Assert.Equal(118, compared.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -433,7 +433,7 @@ public sealed class HlslSourceCompatibilityTests
|
|||
return "using CultMath;\nusing static CultMath.math;\nnamespace CultMathHlsl;\npublic class HlslShader\n{\n" + source + "\n}\n";
|
||||
}
|
||||
|
||||
private static (Assembly? Assembly, IReadOnlyList<string> Errors) CompileShaderMirror()
|
||||
internal static (Assembly? Assembly, IReadOnlyList<string> Errors) CompileShaderMirror()
|
||||
{
|
||||
var hlsl = ReadShaderSource(FindCultMathRoot());
|
||||
var tree = CSharpSyntaxTree.ParseText(TransformHlslToCSharp(hlsl));
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,75 @@
|
|||
{
|
||||
"oracle": "tools/cycles-oracle/oracle.py (Blender 5.2.2 LTS, build d13f752e3b9c, Cycles CPU, 1 sample, Emit bake, float Non-Color images)",
|
||||
"blender": "5.2.2 LTS",
|
||||
"build_hash": "d13f752e3b9c",
|
||||
"platform": "Linux",
|
||||
"encoding": "each case is 'operands -> results', every scalar a float32 bit pattern in hex, colours flattened as r g b",
|
||||
"family": "infinite",
|
||||
"functions": [
|
||||
{
|
||||
"function": "cycles_power",
|
||||
"node": "ShaderNodeMath POWER",
|
||||
"cases": [
|
||||
"bf800000 inf -> 3f800000",
|
||||
"bf800000 -inf -> 3f800000",
|
||||
"c0000000 inf -> inf",
|
||||
"c0000000 -inf -> 00000000",
|
||||
"bf000000 inf -> 00000000",
|
||||
"bf000000 -inf -> inf",
|
||||
"80000000 inf -> 00000000",
|
||||
"80000000 -inf -> 00000000",
|
||||
"3f000000 inf -> 00000000",
|
||||
"3f000000 -inf -> inf",
|
||||
"40000000 inf -> inf",
|
||||
"40000000 -inf -> 00000000",
|
||||
"3f800000 inf -> 3f800000",
|
||||
"00000000 inf -> 00000000",
|
||||
"00000000 -inf -> 00000000",
|
||||
"inf 40400000 -> inf",
|
||||
"-inf 40400000 -> -inf",
|
||||
"-inf 40000000 -> inf",
|
||||
"inf bf800000 -> 00000000",
|
||||
"-inf bf800000 -> 00000000",
|
||||
"-inf inf -> inf",
|
||||
"inf inf -> inf"
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "cycles_modulo",
|
||||
"node": "ShaderNodeMath MODULO",
|
||||
"cases": [
|
||||
"inf 3f800000 -> nan",
|
||||
"-inf 3f800000 -> nan",
|
||||
"inf 40400000 -> nan",
|
||||
"-inf 40400000 -> nan",
|
||||
"inf 3dcccccd -> nan",
|
||||
"-inf 3dcccccd -> nan",
|
||||
"inf c0000000 -> nan",
|
||||
"40a00000 inf -> 40a00000",
|
||||
"c0a00000 inf -> c0a00000",
|
||||
"40a00000 -inf -> 40a00000",
|
||||
"inf inf -> nan",
|
||||
"-inf inf -> nan",
|
||||
"00000000 inf -> 00000000",
|
||||
"inf 00000000 -> 00000000"
|
||||
]
|
||||
},
|
||||
{
|
||||
"function": "cycles_map_range_smoothstep",
|
||||
"node": "ShaderNodeMapRange SMOOTHSTEP",
|
||||
"cases": [
|
||||
"-inf -inf 3f800000 40000000 40400000 -> nan",
|
||||
"-inf 3f800000 -inf 40000000 40400000 -> nan",
|
||||
"-inf 3f800000 40a00000 40000000 40400000 -> 40000000",
|
||||
"-inf c0a00000 3f800000 40000000 40400000 -> 40000000",
|
||||
"inf c0a00000 3f800000 40000000 40400000 -> 40400000",
|
||||
"inf 3f800000 c0a00000 40000000 40400000 -> 40000000",
|
||||
"inf inf 3f800000 40000000 40400000 -> 40000000",
|
||||
"inf 3f800000 inf 40000000 40400000 -> 40400000",
|
||||
"-inf inf 3f800000 40000000 40400000 -> 40400000",
|
||||
"-inf 3f800000 inf 40000000 40400000 -> 40000000",
|
||||
"3f800000 -inf inf 40000000 40400000 -> nan"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
20610
packages/cultmath/tests/CultMath.Tests/fixtures/cycles-oracle/math.json
Normal file
20610
packages/cultmath/tests/CultMath.Tests/fixtures/cycles-oracle/math.json
Normal file
File diff suppressed because it is too large
Load diff
21640
packages/cultmath/tests/CultMath.Tests/fixtures/cycles-oracle/mix.json
Normal file
21640
packages/cultmath/tests/CultMath.Tests/fixtures/cycles-oracle/mix.json
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -5,6 +5,9 @@ param(
|
|||
# Also concatenate shaders\CultMath.Phacelle.glsl (MPL-2.0) after CultMath.glsl, as a consumer that
|
||||
# calls cultmath_phacelle does. Without it the wrapper is the MIT library alone.
|
||||
[switch]$Phacelle,
|
||||
# Also concatenate shaders\CultMath.Cycles.glsl (Apache-2.0), as a consumer that calls a cultmath_cycles_
|
||||
# function does.
|
||||
[switch]$Cycles,
|
||||
[string]$GlslangPath = ""
|
||||
)
|
||||
|
||||
|
|
@ -23,6 +26,9 @@ $library = Get-Content -LiteralPath (Join-Path $repoRoot "shaders\CultMath.glsl"
|
|||
if ($Phacelle) {
|
||||
$library += Get-Content -LiteralPath (Join-Path $repoRoot "shaders\CultMath.Phacelle.glsl") -Raw
|
||||
}
|
||||
if ($Cycles) {
|
||||
$library += Get-Content -LiteralPath (Join-Path $repoRoot "shaders\CultMath.Cycles.glsl") -Raw
|
||||
}
|
||||
$calls = foreach ($match in [regex]::Matches($library, '(?m)^\w+\s+(cultmath_\w+)\s*\(([^)]*)\)')) {
|
||||
$arguments = foreach ($parameter in ($match.Groups[2].Value -split ',')) {
|
||||
$type = ($parameter.Trim() -split '\s+')[0]
|
||||
|
|
|
|||
70
packages/cultmath/tools/cycles-oracle/README.md
Normal file
70
packages/cultmath/tools/cycles-oracle/README.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# Cycles oracle
|
||||
|
||||
`oracle.py` produces the fixtures that `CyclesOracleTests` holds `math.cycles_*` to: for each Cycles node
|
||||
CultMath ports, the node's results as Blender's own Cycles computes them.
|
||||
|
||||
## Run
|
||||
|
||||
```
|
||||
CYCLES_CPU_NO_AVX2=1 blender --background --factory-startup --python oracle.py -- <fixture directory> [<family> ...]
|
||||
```
|
||||
|
||||
The fixture directory is `tests/CultMath.Tests/fixtures/cycles-oracle`. Families are `math`, `map-range`,
|
||||
`clamp`, `mix`, `rgb-to-bw` and `ramp`; with none named, all are written. The tool is a Blender Python
|
||||
script and runs only under Blender, never under a plain Python.
|
||||
|
||||
## The pin
|
||||
|
||||
It refuses to run under any Blender but **5.2.2 LTS, build hash `d13f752e3b9c`**, exits nonzero and names
|
||||
the version and build it found beside the ones it needs. Blender 5.2.2 is
|
||||
<https://download.blender.org/release/Blender5.2/> (`blender-5.2.2-linux-x64.tar.xz`). The platform is Linux x64: the fixtures reproduce
|
||||
byte for byte only there, and the Steam/Windows build is another build (see below). The fixtures record the
|
||||
version, the hash and the platform they were written on, as provenance.
|
||||
|
||||
## The reference and other builds
|
||||
|
||||
Ruling `cultmath-tapes:ruling:cycles-kernel-reference-ruled`: CultMath's Cycles converters mean Cycles' formulas
|
||||
evaluated with IEEE single rounding per operation, and the Linux SSE4.2 oracle stays their bit-exact witness.
|
||||
Every other Cycles build (AVX2 FMA-contracted, MSVC /fp:fast, GPU backends) and CultMath's own HLSL lowering are
|
||||
held to a stated per-function tolerance class measured on that build; cancellation-prone functions (floored
|
||||
modulo, wrap) get an error bound relative to |b| and a documented divergence note.
|
||||
|
||||
Cycles' AVX2 CPU kernel is compiled with fused multiply-add, so `a - floor(a / b) b` is one fused operation
|
||||
there, and CultMath's C# (which fuses nothing) computes the unfused one. The fixtures pin the SSE4.2 kernel,
|
||||
which has no FMA, by `CYCLES_CPU_NO_AVX2=1`; the tool bakes `10000 mod 0.1` before anything else and exits 4 if
|
||||
the result is the fused one, so a run on the wrong kernel writes nothing. Other kernels are other builds:
|
||||
measured under the table below, never written to these fixtures.
|
||||
|
||||
| build | how it was measured | fixture | per-function ulp or bound |
|
||||
| --- | --- | --- | --- |
|
||||
| Blender 5.2.2 Linux x64, SSE4.2 kernel (the reference) | `oracle.py` with `CYCLES_CPU_NO_AVX2=1`; `CyclesOracleTests` holds every function to the fixtures | `tests/CultMath.Tests/fixtures/cycles-oracle/*.json` | 0 ulp, every function |
|
||||
| Blender 5.2.2 Linux x64, AVX2 kernel | the same oracle without the pin, 1024 rows per function, against the single-rounding model `fma(-floor(a / b), b, a)` (wrap: `fma(-range, floor((value - min) / range), value)`) | none committed | floored modulo: at most 2^-23 (\|a / b\| + 1) \|b\|, measured 0.91 of 2^-24 (\|a\| + \|b\|) on 151 rows; wrap: at most 2^-23 (\|value\| + \|min\| + \|range\|), measured 0.77 of 2^-24 on 30 rows; other functions unmeasured |
|
||||
|
||||
The divergence note: a build compiled with fast-math may also move `floor(a / b)` by one near an integer
|
||||
quotient, so the result differs by a whole \|b\| (wrap: a whole range). Further rows come from follow-up
|
||||
`cultmath-tapes:follow_up:cycles-tolerance-classes`; its per-build fixtures go under
|
||||
`fixtures/cycles-oracle/<build>/` with their tolerances beside `Tolerances` in `CyclesOracleTests`.
|
||||
|
||||
## Method
|
||||
|
||||
Operands go into float, Non-Color input images (channel packed, read with Closest interpolation), three
|
||||
operands per image. The node under test reads them. One Emit bake per output channel (Cycles on the CPU, 1
|
||||
sample, margin 0, no denoising) writes the result to a float image; each pixel is one case. The tool first
|
||||
checks that a bake returns an operand image bit for bit, and stops if it does not. The fixtures hold the
|
||||
operands and the results as float32 bit patterns, so a test reads exactly what Cycles computed.
|
||||
|
||||
A Map Range node's Clamp option is baked as the node with Clamp on, which Cycles expands into a Clamp node
|
||||
of type Range on the result. The ramp family bakes a Color Ramp whose table entries are each read at a `t`
|
||||
whose product with 255 is the entry's index, so the first 256 cases are the ramp's own table.
|
||||
|
||||
## Fixtures
|
||||
|
||||
Written by this tool and never edited by hand. Rerunning it under the same Blender build on the same
|
||||
platform rewrites them byte for byte; `CyclesOracleTests` holds every function to them at 0 ulp. Run the
|
||||
oracle on Yggdrasil-class Linux, never on the workstation.
|
||||
|
||||
## Licence
|
||||
|
||||
`oracle.py` is GPL-3.0-or-later, as a script that runs inside Blender (`bpy`). It ships in no package: the
|
||||
`GameCult.Math` NuGet package and the Unity package do not contain `tools/`. The fixtures are data it
|
||||
produced. The Cycles functions they test are Apache-2.0 (`math.Cycles.cs`, `CultMath.Cycles.hlsl`).
|
||||
699
packages/cultmath/tools/cycles-oracle/oracle.py
Normal file
699
packages/cultmath/tools/cycles-oracle/oracle.py
Normal file
|
|
@ -0,0 +1,699 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# The Cycles oracle for CultMath's Cycles node semantics (math.Cycles.cs, shaders/CultMath.Cycles.hlsl).
|
||||
# A Blender Python script, so it is GPL-3.0-or-later; it is a test tool and ships in no package.
|
||||
#
|
||||
# blender --background --factory-startup --python oracle.py -- <fixture directory> [<family> ...]
|
||||
#
|
||||
# It refuses to run under any Blender but 5.2.2 LTS, build hash d13f752e3b9c. For each family it bakes
|
||||
# the node under test with Cycles on the CPU and writes tests/CultMath.Tests/fixtures/cycles-oracle/<family>.json.
|
||||
# Method: operands go into float, Non-Color input images read with Closest interpolation; one Emit bake per
|
||||
# output channel (1 sample, margin 0, no denoising) writes the node's result to a float image whose pixels are
|
||||
# the results, bit for bit. The fixtures are this tool's output and are never edited by hand. They witness the
|
||||
# reference of ruling cultmath-tapes:ruling:cycles-kernel-reference-ruled (one IEEE single rounding per operation,
|
||||
# the SSE4.2 kernel, CYCLES_CPU_NO_AVX2=1); other kernels are other builds and are never written here.
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
PINNED_VERSION = "5.2.2 LTS"
|
||||
PINNED_HASH = "d13f752e3b9c"
|
||||
SIDE = 32
|
||||
CASES = SIDE * SIDE
|
||||
|
||||
|
||||
def refuse_other_blender():
|
||||
found = (bpy.app.version_string, bpy.app.build_hash.decode() if isinstance(bpy.app.build_hash, bytes) else bpy.app.build_hash)
|
||||
if found != (PINNED_VERSION, PINNED_HASH):
|
||||
sys.stderr.write(
|
||||
"cycles-oracle: needs Blender %s build %s, found %s build %s\n" % (PINNED_VERSION, PINNED_HASH, found[0], found[1]))
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
refuse_other_blender()
|
||||
|
||||
scene = bpy.context.scene
|
||||
scene.render.engine = "CYCLES"
|
||||
scene.cycles.device = "CPU"
|
||||
scene.cycles.samples = 1
|
||||
scene.cycles.use_denoising = False
|
||||
bpy.ops.mesh.primitive_plane_add(size=2)
|
||||
PLANE = bpy.context.active_object
|
||||
|
||||
|
||||
def by_identifier(sockets, identifier):
|
||||
"""A node's socket by identifier: several data types share one socket name."""
|
||||
for socket in sockets:
|
||||
if socket.identifier == identifier:
|
||||
return socket
|
||||
raise KeyError("no socket %s among %s" % (identifier, [s.identifier for s in sockets]))
|
||||
|
||||
|
||||
def f32(values):
|
||||
return np.asarray(values, dtype=np.float32)
|
||||
|
||||
|
||||
def bits(value):
|
||||
return "%08x" % int(np.asarray(value, dtype=np.float32).view(np.uint32))
|
||||
|
||||
|
||||
def rng_for(name):
|
||||
# One stream per name: a function, a blend mode's second colours (name + "2"), a ramp. Sharing a seed shares the draws.
|
||||
return np.random.default_rng(int.from_bytes(hashlib.sha256(name.encode()).digest()[:8], "little"))
|
||||
|
||||
|
||||
def new_image(name, rgba=None):
|
||||
image = bpy.data.images.new(name, SIDE, SIDE, alpha=True, float_buffer=True)
|
||||
image.colorspace_settings.name = "Non-Color"
|
||||
image.alpha_mode = "CHANNEL_PACKED"
|
||||
if rgba is not None:
|
||||
image.pixels.foreach_set(rgba.reshape(-1))
|
||||
image.pack()
|
||||
return image
|
||||
|
||||
|
||||
def pack_operands(columns):
|
||||
"""Operand columns (CASES float32 each) to input images of three operands per image."""
|
||||
images = []
|
||||
for first in range(0, len(columns), 3):
|
||||
rgba = np.zeros((SIDE * SIDE, 4), dtype=np.float32)
|
||||
rgba[:, 3] = 1.0
|
||||
for k, column in enumerate(columns[first:first + 3]):
|
||||
rgba[:, k] = column
|
||||
images.append(new_image("operands%d" % first, rgba.reshape(SIDE, SIDE, 4)))
|
||||
return images
|
||||
|
||||
|
||||
def operand_sockets(tree, images, count):
|
||||
"""One float socket per operand, read from the input images with Closest interpolation."""
|
||||
sockets = []
|
||||
for image in images:
|
||||
texture = tree.nodes.new("ShaderNodeTexImage")
|
||||
texture.image = image
|
||||
texture.interpolation = "Closest"
|
||||
separate = tree.nodes.new("ShaderNodeSeparateColor")
|
||||
tree.links.new(texture.outputs["Color"], separate.inputs[0])
|
||||
sockets.append([separate.outputs[0], separate.outputs[1], separate.outputs[2]])
|
||||
flat = [socket for triple in sockets for socket in triple]
|
||||
return flat[:count]
|
||||
|
||||
|
||||
def triple_socket(tree, images, first):
|
||||
"""The colour made of operands first, first + 1, first + 2."""
|
||||
combine = tree.nodes.new("ShaderNodeCombineColor")
|
||||
sockets = operand_sockets(tree, images, first + 3)
|
||||
for k in range(3):
|
||||
tree.links.new(sockets[first + k], combine.inputs[k])
|
||||
return combine.outputs[0]
|
||||
|
||||
|
||||
def bake(label, images, build, channels):
|
||||
"""Bakes build(tree, images) once per output channel; returns channels float32 arrays of CASES results."""
|
||||
results = []
|
||||
for channel in range(channels):
|
||||
material = bpy.data.materials.new("%s.%d" % (label, channel))
|
||||
material.use_nodes = True
|
||||
tree = material.node_tree
|
||||
tree.nodes.clear()
|
||||
output = tree.nodes.new("ShaderNodeOutputMaterial")
|
||||
emit = tree.nodes.new("ShaderNodeEmission")
|
||||
tree.links.new(emit.outputs[0], output.inputs[0])
|
||||
sockets = build(tree, images)
|
||||
socket = sockets[channel]
|
||||
if socket.type == "RGBA" or socket.type == "VECTOR":
|
||||
separate = tree.nodes.new("ShaderNodeSeparateColor")
|
||||
tree.links.new(socket, separate.inputs[0])
|
||||
# Cycles stores a colour result channel by channel; the channel under test is red.
|
||||
tree.links.new(separate.outputs[0], emit.inputs["Color"])
|
||||
else:
|
||||
tree.links.new(socket, emit.inputs["Color"])
|
||||
target = new_image("%s.%d.out" % (label, channel))
|
||||
target_node = tree.nodes.new("ShaderNodeTexImage")
|
||||
target_node.image = target
|
||||
tree.nodes.active = target_node
|
||||
PLANE.data.materials.clear()
|
||||
PLANE.data.materials.append(material)
|
||||
bpy.context.view_layer.objects.active = PLANE
|
||||
PLANE.select_set(True)
|
||||
bpy.ops.object.bake(type="EMIT", margin=0)
|
||||
pixels = np.empty(SIDE * SIDE * 4, dtype=np.float32)
|
||||
target.pixels.foreach_get(pixels)
|
||||
results.append(pixels.reshape(SIDE * SIDE, 4)[:, 0].copy())
|
||||
return results
|
||||
|
||||
|
||||
def vector_bake(label, images, build):
|
||||
"""For a node whose result is a colour: one bake per RGB channel via Separate Color."""
|
||||
results = []
|
||||
for channel in range(3):
|
||||
def pick(tree, imgs, channel=channel):
|
||||
color = build(tree, imgs)
|
||||
separate = tree.nodes.new("ShaderNodeSeparateColor")
|
||||
tree.links.new(color, separate.inputs[0])
|
||||
return [separate.outputs[channel]]
|
||||
results.append(bake("%s.c%d" % (label, channel), images, pick, 1)[0])
|
||||
return results
|
||||
|
||||
|
||||
# ---- operand sets ----
|
||||
|
||||
SPECIAL = [0.0, 1.0, -1.0, 0.5, -0.5, 2.0, -2.0, 3.0, -2.75, 0.1, -0.1, 7.5, -7.5, 1.0e-7, -1.0e-7, 100.0, -100.0, 1.0e4, 0.3, 0.7]
|
||||
POSITIVE = [0.0, 1.0, 0.5, 2.0, 3.0, 0.1, 10.0, 7.5, 1.0e-3, 100.0, 0.3, 0.7, 2.718281828]
|
||||
FACTOR = [0.0, 0.5, 1.0, 1.5, -0.5, 0.25, 0.75, 2.0]
|
||||
COLOR_SPECIAL = [0.0, 0.25, 0.5, 0.75, 1.0, 1.25, -0.25, 0.1, 0.9, 0.3333333, 0.6666667]
|
||||
|
||||
|
||||
def sample_columns(name, count, specials, lows_highs, pair_specials=2):
|
||||
"""CASES rows of `count` operands: the specials' product for the first operands, then uniform draws."""
|
||||
rng = rng_for(name)
|
||||
rows = []
|
||||
import itertools
|
||||
lists = [specials[k] if k < len(specials) else None for k in range(count)]
|
||||
fixed = [lst for lst in lists if lst is not None]
|
||||
if fixed:
|
||||
for combo in itertools.product(*fixed):
|
||||
row = list(combo) + [None] * (count - len(combo))
|
||||
rows.append(row)
|
||||
rows = rows[:CASES]
|
||||
while len(rows) < CASES:
|
||||
rows.append([None] * count)
|
||||
columns = []
|
||||
for k in range(count):
|
||||
low, high = lows_highs[k]
|
||||
column = np.empty(CASES, dtype=np.float32)
|
||||
for r, row in enumerate(rows):
|
||||
column[r] = row[k] if row[k] is not None else rng.uniform(low, high)
|
||||
columns.append(column)
|
||||
return columns
|
||||
|
||||
|
||||
def random_color_rows(name, extra=0):
|
||||
"""CASES colours: primaries, secondaries, greys, the specials' products, then random with tied channels."""
|
||||
rng = rng_for(name)
|
||||
rows = []
|
||||
for r in COLOR_SPECIAL[:5]:
|
||||
for g in COLOR_SPECIAL[:5]:
|
||||
for b in COLOR_SPECIAL[:5]:
|
||||
rows.append((r, g, b))
|
||||
for v in COLOR_SPECIAL:
|
||||
rows.append((v, v, v))
|
||||
while len(rows) < CASES:
|
||||
c = list(rng.uniform(-0.25, 1.25, 3))
|
||||
tie = rng.integers(0, 6)
|
||||
if tie == 0:
|
||||
c[1] = c[0]
|
||||
elif tie == 1:
|
||||
c[2] = c[1]
|
||||
elif tie == 2:
|
||||
c[2] = c[0]
|
||||
rows.append(tuple(c))
|
||||
return rows[:CASES]
|
||||
|
||||
|
||||
# ---- families ----
|
||||
|
||||
MATH = [
|
||||
# (function, operation, operand count, operand specials, ranges)
|
||||
("cycles_divide", "DIVIDE", 2, [SPECIAL, SPECIAL], [(-20, 20)] * 2),
|
||||
("cycles_power", "POWER", 2, [SPECIAL, SPECIAL], [(-4, 4), (-4, 4)]),
|
||||
("cycles_logarithm", "LOGARITHM", 2, [SPECIAL, SPECIAL], [(0.01, 20), (0.01, 20)]),
|
||||
("cycles_sqrt", "SQRT", 1, [SPECIAL], [(-5, 20)]),
|
||||
("cycles_inverse_sqrt", "INVERSE_SQRT", 1, [SPECIAL], [(-5, 20)]),
|
||||
("cycles_modulo", "MODULO", 2, [SPECIAL, SPECIAL], [(-20, 20)] * 2),
|
||||
("cycles_floored_modulo", "FLOORED_MODULO", 2, [SPECIAL, SPECIAL], [(-20, 20)] * 2),
|
||||
("cycles_wrap", "WRAP", 3, [SPECIAL[:10], SPECIAL[:10], SPECIAL[:10]], [(-20, 20), (-10, 10), (-10, 10)]),
|
||||
("cycles_snap", "SNAP", 2, [SPECIAL, SPECIAL], [(-20, 20)] * 2),
|
||||
("cycles_pingpong", "PINGPONG", 2, [SPECIAL, SPECIAL], [(-20, 20)] * 2),
|
||||
("cycles_smooth_min", "SMOOTH_MIN", 3, [SPECIAL[:10], SPECIAL[:10], SPECIAL[:10]], [(-20, 20), (-20, 20), (-3, 3)]),
|
||||
("cycles_smooth_max", "SMOOTH_MAX", 3, [SPECIAL[:10], SPECIAL[:10], SPECIAL[:10]], [(-20, 20), (-20, 20), (-3, 3)]),
|
||||
("cycles_compare", "COMPARE", 3, [SPECIAL[:10], SPECIAL[:10], [0.0, 0.5, 1.0, 1.0e-7]], [(-20, 20), (-20, 20), (0, 2)]),
|
||||
("cycles_round", "ROUND", 1, [SPECIAL], [(-20, 20)]),
|
||||
("cycles_trunc", "TRUNC", 1, [SPECIAL], [(-20, 20)]),
|
||||
("cycles_fract", "FRACT", 1, [SPECIAL], [(-20, 20)]),
|
||||
("cycles_sign", "SIGN", 1, [SPECIAL], [(-20, 20)]),
|
||||
("cycles_arcsine", "ARCSINE", 1, [SPECIAL], [(-1.5, 1.5)]),
|
||||
("cycles_arccosine", "ARCCOSINE", 1, [SPECIAL], [(-1.5, 1.5)]),
|
||||
("cycles_arctan2", "ARCTAN2", 2, [SPECIAL, SPECIAL], [(-20, 20)] * 2),
|
||||
]
|
||||
|
||||
# Operands that must be present as exact pairs whatever the random draws: the quirks the port names.
|
||||
EXTRA_ROWS = {
|
||||
"cycles_power": [(-2.0, 1.0e10), (-2.0, -1.0e10), (-2.0, 3.0), (-2.0, 2.0), (-2.0, -3.0), (-2.0, 2147483648.0), (-2.0, -2147483648.0),
|
||||
(-8.0, 0.3333333), (2.0, 0.0), (0.0, 0.0), (0.0, 2.0), (-0.0, -3.0), (-1.5, 17.0), (-1.5, 16777217.0),
|
||||
# A base of -1 keeps a finite result for any exponent, so the film shows the exponent past the int range.
|
||||
(-1.0, 3.0e9), (-1.0, -3.0e9), (-1.0, 2147483648.0), (-1.0, 16777216.0), (-1.0, 16777215.0)],
|
||||
"cycles_compare": [(0.0, 1.0e-7, 0.0), (1.0, 1.0000001, 0.0), (1.0, 1.0000002, 0.0), (0.0, 1.0e-7, 1.0e-8), (5.0, 5.0, 0.0), (0.0, 2.0e-7, 1.0e-7)],
|
||||
# Burn with a divisor of exactly 0 (tm + t col2 = 0) and a colour above 1: the result is 1 for the channel Cycles reads
|
||||
# as 0, and an infinity the film would hide for the others.
|
||||
"cycles_mix_burn": [(2.0, 1.5, 0.5, 2.5, 0.5, 0.5, 0.5), (0.5, 1.5, 2.0, 3.0, -1.0, -1.0, -1.0)],
|
||||
# safe_asinf / safe_acosf clamp to [-1, 1]; compatible_atan2 guards only the origin (y = a, x = b).
|
||||
"cycles_arcsine": [(2.0,), (-2.0,), (1.0,), (-1.0,), (1.0000001,), (-1.0000001,), (1.5,)],
|
||||
"cycles_arccosine": [(2.0,), (-2.0,), (1.0,), (-1.0,), (1.0000001,), (-1.0000001,), (1.5,)],
|
||||
"cycles_arctan2": [(0.0, 0.0), (0.0, -0.0), (-0.0, -0.0), (-0.0, -1.0), (0.0, -1.0), (1.0, 0.0), (-1.0, 0.0), (1.0e-7, -1.0e-7)],
|
||||
"cycles_modulo": [(0.3, 0.1), (-0.3, 0.1), (5.5, -2.0), (-5.5, -2.0), (1.0, 0.0), (1.0e7, 3.0), (7.0, 7.0), (-7.0, 7.0)],
|
||||
}
|
||||
|
||||
MAP_RANGE = [
|
||||
("cycles_map_range_linear", "LINEAR"),
|
||||
("cycles_map_range_stepped", "STEPPED"),
|
||||
("cycles_map_range_smoothstep", "SMOOTHSTEP"),
|
||||
("cycles_map_range_smootherstep", "SMOOTHERSTEP"),
|
||||
]
|
||||
|
||||
MIX_MODES = [
|
||||
("cycles_mix_blend", "MIX"), ("cycles_mix_add", "ADD"), ("cycles_mix_mul", "MULTIPLY"), ("cycles_mix_screen", "SCREEN"),
|
||||
("cycles_mix_overlay", "OVERLAY"), ("cycles_mix_sub", "SUBTRACT"), ("cycles_mix_div", "DIVIDE"), ("cycles_mix_diff", "DIFFERENCE"),
|
||||
("cycles_mix_exclusion", "EXCLUSION"), ("cycles_mix_dark", "DARKEN"), ("cycles_mix_light", "LIGHTEN"), ("cycles_mix_dodge", "DODGE"),
|
||||
("cycles_mix_burn", "BURN"), ("cycles_mix_hue", "HUE"), ("cycles_mix_sat", "SATURATION"), ("cycles_mix_val", "VALUE"),
|
||||
("cycles_mix_color", "COLOR"), ("cycles_mix_soft", "SOFT_LIGHT"), ("cycles_mix_linear", "LINEAR_LIGHT"),
|
||||
]
|
||||
|
||||
|
||||
def with_extra(function, columns, count):
|
||||
extra = EXTRA_ROWS.get(function, [])
|
||||
for r, row in enumerate(extra):
|
||||
for k in range(count):
|
||||
columns[k][CASES - 1 - r] = row[k] if k < len(row) else 0.0
|
||||
return columns
|
||||
|
||||
|
||||
def encode_cases(columns, results):
|
||||
cases = []
|
||||
for r in range(CASES):
|
||||
cases.append("%s -> %s" % (" ".join(bits(c[r]) for c in columns), " ".join(bits(x[r]) for x in results)))
|
||||
return cases
|
||||
|
||||
|
||||
def family_math():
|
||||
functions = []
|
||||
for function, operation, count, specials, ranges in MATH:
|
||||
columns = with_extra(function, sample_columns(function, count, specials, ranges), count)
|
||||
images = pack_operands(columns)
|
||||
|
||||
def build(tree, imgs, operation=operation, count=count):
|
||||
node = tree.nodes.new("ShaderNodeMath")
|
||||
node.operation = operation
|
||||
sockets = operand_sockets(tree, imgs, count)
|
||||
for k in range(count):
|
||||
tree.links.new(sockets[k], node.inputs[k])
|
||||
return [node.outputs[0]]
|
||||
|
||||
functions.append({"function": function, "node": "ShaderNodeMath " + operation, "cases": encode_cases(columns, bake(function, images, build, 1))})
|
||||
return functions
|
||||
|
||||
|
||||
def family_map_range():
|
||||
functions = []
|
||||
for function, kind in MAP_RANGE:
|
||||
count = 6
|
||||
specials = [SPECIAL[:10], SPECIAL[:5], SPECIAL[:5]]
|
||||
columns = sample_columns(function, count, specials, [(-20, 20), (-10, 10), (-10, 10), (-20, 20), (-20, 20), (-1, 8)])
|
||||
# Rows with From Min equal to From Max, and with From Min above From Max, whatever the draws.
|
||||
for r in range(8):
|
||||
columns[2][CASES - 1 - r] = columns[1][CASES - 1 - r]
|
||||
for r in range(8, 16):
|
||||
columns[1][CASES - 1 - r], columns[2][CASES - 1 - r] = max(columns[1][CASES - 1 - r], columns[2][CASES - 1 - r]), min(columns[1][CASES - 1 - r], columns[2][CASES - 1 - r])
|
||||
for r in range(16, 24):
|
||||
columns[5][CASES - 1 - r] = float(r - 18)
|
||||
if kind == "SMOOTHSTEP":
|
||||
# An overflowing range: from_max - from_min is infinite, and Cycles' smoothstep decides x >= edge1 before dividing.
|
||||
for r, row in ((24, (3.0e38, -3.0e38, 3.0e38, 0.0, 1.0, 4.0)), (25, (3.0e38, 3.0e38, -3.0e38, 0.0, 1.0, 4.0))):
|
||||
for k in range(6):
|
||||
columns[k][CASES - 1 - r] = row[k]
|
||||
images = pack_operands(columns)
|
||||
|
||||
def build(tree, imgs, kind=kind):
|
||||
node = tree.nodes.new("ShaderNodeMapRange")
|
||||
node.data_type = "FLOAT"
|
||||
node.interpolation_type = kind
|
||||
node.clamp = False
|
||||
sockets = operand_sockets(tree, imgs, 6)
|
||||
for k, identifier in enumerate(["Value", "From Min", "From Max", "To Min", "To Max", "Steps"]):
|
||||
socket = by_identifier(node.inputs, identifier)
|
||||
tree.links.new(sockets[k], socket)
|
||||
return [node.outputs["Result"]]
|
||||
|
||||
functions.append({"function": function, "node": "ShaderNodeMapRange " + kind, "cases": encode_cases(columns, bake(function, images, build, 1))})
|
||||
# The Clamp option, on the same operands: a Clamp node of type Range on the result.
|
||||
def build_clamped(tree, imgs, kind=kind):
|
||||
node = tree.nodes.new("ShaderNodeMapRange")
|
||||
node.data_type = "FLOAT"
|
||||
node.interpolation_type = kind
|
||||
node.clamp = True
|
||||
sockets = operand_sockets(tree, imgs, 6)
|
||||
for k, identifier in enumerate(["Value", "From Min", "From Max", "To Min", "To Max", "Steps"]):
|
||||
socket = by_identifier(node.inputs, identifier)
|
||||
tree.links.new(sockets[k], socket)
|
||||
return [node.outputs["Result"]]
|
||||
|
||||
functions.append({"function": function, "node": "ShaderNodeMapRange " + kind + " with Clamp",
|
||||
"then": "cycles_clamp_range(result, to_min, to_max)",
|
||||
"cases": encode_cases(columns, bake(function + ".clamped", images, build_clamped, 1))})
|
||||
return functions
|
||||
|
||||
|
||||
def family_clamp():
|
||||
functions = []
|
||||
for function, clamp_type in (("cycles_clamp_minmax", "MINMAX"), ("cycles_clamp_range", "RANGE")):
|
||||
columns = sample_columns(function, 3, [SPECIAL[:10], SPECIAL[:10], SPECIAL[:10]], [(-20, 20)] * 3)
|
||||
images = pack_operands(columns)
|
||||
|
||||
def build(tree, imgs, clamp_type=clamp_type):
|
||||
node = tree.nodes.new("ShaderNodeClamp")
|
||||
node.clamp_type = clamp_type
|
||||
sockets = operand_sockets(tree, imgs, 3)
|
||||
for k in range(3):
|
||||
tree.links.new(sockets[k], node.inputs[k])
|
||||
return [node.outputs[0]]
|
||||
|
||||
functions.append({"function": function, "node": "ShaderNodeClamp " + clamp_type, "cases": encode_cases(columns, bake(function, images, build, 1))})
|
||||
return functions
|
||||
|
||||
|
||||
def mix_columns(name, blend):
|
||||
rng = rng_for(name)
|
||||
colors = random_color_rows(name)
|
||||
colors2 = random_color_rows(name + "2")
|
||||
rng.shuffle(colors2)
|
||||
factors = [FACTOR[r % len(FACTOR)] if r < 8 * 64 else float(rng.uniform(-0.5, 1.5)) for r in range(CASES)]
|
||||
columns = [f32(factors)]
|
||||
columns += [f32([c[k] for c in colors]) for k in range(3)]
|
||||
columns += [f32([c[k] for c in colors2]) for k in range(3)]
|
||||
return columns
|
||||
|
||||
|
||||
def family_mix():
|
||||
functions = []
|
||||
rng = rng_for("mix-float")
|
||||
# Mix, Float: a and b in the usual ranges, the factor as in the blend modes.
|
||||
columns = [f32([FACTOR[r % len(FACTOR)] if r < 512 else rng.uniform(-0.5, 1.5) for r in range(CASES)]),
|
||||
f32(rng.uniform(-20, 20, CASES)), f32(rng.uniform(-20, 20, CASES))]
|
||||
images = pack_operands(columns)
|
||||
|
||||
def build_float(tree, imgs):
|
||||
node = tree.nodes.new("ShaderNodeMix")
|
||||
node.data_type = "FLOAT"
|
||||
node.clamp_factor = False
|
||||
node.clamp_result = False
|
||||
sockets = operand_sockets(tree, imgs, 3)
|
||||
for k, identifier in enumerate(["Factor_Float", "A_Float", "B_Float"]):
|
||||
tree.links.new(sockets[k], by_identifier(node.inputs, identifier))
|
||||
return [by_identifier(node.outputs, "Result_Float")]
|
||||
|
||||
functions.append({"function": "cycles_mix_float", "node": "ShaderNodeMix FLOAT", "cases": encode_cases(columns, bake("mix_float", images, build_float, 1))})
|
||||
|
||||
columns = mix_columns("mix-vector", "VECTOR")
|
||||
images = pack_operands(columns)
|
||||
|
||||
def build_vector(tree, imgs):
|
||||
node = tree.nodes.new("ShaderNodeMix")
|
||||
node.data_type = "VECTOR"
|
||||
node.factor_mode = "UNIFORM"
|
||||
node.clamp_factor = False
|
||||
sockets = operand_sockets(tree, imgs, 1)
|
||||
tree.links.new(sockets[0], by_identifier(node.inputs, "Factor_Float"))
|
||||
tree.links.new(triple_socket(tree, imgs, 1), by_identifier(node.inputs, "A_Vector"))
|
||||
tree.links.new(triple_socket(tree, imgs, 4), by_identifier(node.inputs, "B_Vector"))
|
||||
return by_identifier(node.outputs, "Result_Vector")
|
||||
|
||||
functions.append({"function": "cycles_mix_vector", "node": "ShaderNodeMix VECTOR UNIFORM", "cases": encode_cases(columns, vector_bake("mix_vector", images, build_vector))})
|
||||
|
||||
for function, blend in MIX_MODES:
|
||||
columns = with_extra(function, mix_columns(function, blend), 7)
|
||||
images = pack_operands(columns)
|
||||
|
||||
def build(tree, imgs, blend=blend):
|
||||
node = tree.nodes.new("ShaderNodeMix")
|
||||
node.data_type = "RGBA"
|
||||
node.blend_type = blend
|
||||
node.clamp_factor = False
|
||||
node.clamp_result = False
|
||||
sockets = operand_sockets(tree, imgs, 1)
|
||||
tree.links.new(sockets[0], by_identifier(node.inputs, "Factor_Float"))
|
||||
tree.links.new(triple_socket(tree, imgs, 1), by_identifier(node.inputs, "A_Color"))
|
||||
tree.links.new(triple_socket(tree, imgs, 4), by_identifier(node.inputs, "B_Color"))
|
||||
return by_identifier(node.outputs, "Result_Color")
|
||||
|
||||
functions.append({"function": function, "node": "ShaderNodeMix RGBA " + blend, "cases": encode_cases(columns, vector_bake(function, images, build))})
|
||||
return functions
|
||||
|
||||
|
||||
def family_rgb_to_bw():
|
||||
rows = random_color_rows("rgb_to_bw")
|
||||
columns = [f32([c[k] for c in rows]) for k in range(3)]
|
||||
# One case per primary pins the scene-linear coefficients.
|
||||
for k in range(3):
|
||||
for j in range(3):
|
||||
columns[j][CASES - 1 - k] = 1.0 if j == k else 0.0
|
||||
images = pack_operands(columns)
|
||||
|
||||
def build(tree, imgs):
|
||||
node = tree.nodes.new("ShaderNodeRGBToBW")
|
||||
tree.links.new(triple_socket(tree, imgs, 0), node.inputs["Color"])
|
||||
return [node.outputs["Val"]]
|
||||
|
||||
return [{"function": "cycles_rgb_to_bw", "node": "ShaderNodeRGBToBW", "cases": encode_cases(columns, bake("rgb_to_bw", images, build, 1))}]
|
||||
|
||||
|
||||
# A Blender ramp is sampled at 256 intervals into a table of 257 entries (colorramp_to_array: size + 1 entries
|
||||
# at i / size), and the kernel reads that table with table_size 257.
|
||||
RAMP_SIZE = 257
|
||||
# A constant ramp whose 32 stops sit half an entry past an entry boundary: its table is the stops' values, so
|
||||
# a lookup's result names the entry it read.
|
||||
CONSTANT_STOPS = [((8 * j + 3.5) / (RAMP_SIZE - 1), (j + 1) / 32.0) for j in range(32)]
|
||||
|
||||
|
||||
def ramp_cases(rng, boundaries):
|
||||
"""CASES values of t: the floats around each boundary t = i / 255, the specials, then uniform draws."""
|
||||
values = []
|
||||
for i in boundaries:
|
||||
t = np.float32(i / (RAMP_SIZE - 1))
|
||||
down = [t]
|
||||
up = [t]
|
||||
for _ in range(7):
|
||||
down.append(np.nextafter(down[-1], np.float32(-1.0)))
|
||||
up.append(np.nextafter(up[-1], np.float32(2.0)))
|
||||
values += sorted(set(down + up))
|
||||
values += [-0.5, -1.0e-7, 0.0, 1.0e-7, 1.0, 1.0000001, 1.5]
|
||||
while len(values) < CASES:
|
||||
values.append(float(rng.uniform(-0.25, 1.25)))
|
||||
return f32(values[:CASES])
|
||||
|
||||
|
||||
def ramp_entry(label, interpolation, stops, boundaries):
|
||||
rng = rng_for("ramp-" + label)
|
||||
column = ramp_cases(rng, boundaries)
|
||||
images = pack_operands([column])
|
||||
|
||||
def build(tree, imgs):
|
||||
node = tree.nodes.new("ShaderNodeValToRGB")
|
||||
ramp = node.color_ramp
|
||||
ramp.interpolation = interpolation
|
||||
ramp.elements[0].position = stops[0][0]
|
||||
ramp.elements[1].position = stops[-1][0]
|
||||
for position, _ in stops[1:-1]:
|
||||
ramp.elements.new(position)
|
||||
for element, (_, red) in zip(ramp.elements, stops):
|
||||
element.color = (red, 0.0, 0.0, 1.0)
|
||||
tree.links.new(operand_sockets(tree, imgs, 1)[0], node.inputs["Fac"])
|
||||
return [node.outputs["Color"]]
|
||||
|
||||
return {"function": "cycles_ramp_index", "node": "ShaderNodeValToRGB " + interpolation, "table_entries": RAMP_SIZE,
|
||||
"stops": [[bits(position), bits(red)] for position, red in stops],
|
||||
"cases": encode_cases([column], bake("ramp-" + label, images, build, 1))}
|
||||
|
||||
|
||||
def family_ramp():
|
||||
boundaries = [8 * j + 4 for j in range(32)]
|
||||
return [
|
||||
ramp_entry("constant", "CONSTANT", CONSTANT_STOPS, boundaries),
|
||||
ramp_entry("linear", "LINEAR", [(0.0, 0.0), (1.0, 1.0)], list(range(1, RAMP_SIZE, 17))),
|
||||
]
|
||||
|
||||
|
||||
# ---- infinite operands ----
|
||||
|
||||
INF = float("inf")
|
||||
|
||||
# Rows of (function, node, operands) whose operands may be an infinity. The film writes 0 for an infinite operand and
|
||||
# for an infinite or NaN result, so neither can travel through an image: the operand is made in-kernel (+-1 times
|
||||
# exp(100), which overflows to +-inf) and the result is read back through three comparisons with 0.
|
||||
INFINITE = [
|
||||
("cycles_power", "POWER", [
|
||||
(-1.0, INF), (-1.0, -INF), (-2.0, INF), (-2.0, -INF), (-0.5, INF), (-0.5, -INF), (-0.0, INF), (-0.0, -INF),
|
||||
(0.5, INF), (0.5, -INF), (2.0, INF), (2.0, -INF), (1.0, INF), (0.0, INF), (0.0, -INF), (INF, 3.0), (-INF, 3.0),
|
||||
(-INF, 2.0), (INF, -1.0), (-INF, -1.0), (-INF, INF), (INF, INF)]),
|
||||
("cycles_modulo", "MODULO", [
|
||||
(INF, 1.0), (-INF, 1.0), (INF, 3.0), (-INF, 3.0), (INF, 0.1), (-INF, 0.1), (INF, -2.0), (5.0, INF), (-5.0, INF),
|
||||
(5.0, -INF), (INF, INF), (-INF, INF), (0.0, INF), (INF, 0.0)]),
|
||||
]
|
||||
|
||||
# Map Range, Smooth Step: (value, from_min, from_max, to_min, to_max).
|
||||
INFINITE_SMOOTHSTEP = [
|
||||
(-INF, -INF, 1.0, 2.0, 3.0), (-INF, 1.0, -INF, 2.0, 3.0), (-INF, 1.0, 5.0, 2.0, 3.0), (-INF, -5.0, 1.0, 2.0, 3.0),
|
||||
(INF, -5.0, 1.0, 2.0, 3.0), (INF, 1.0, -5.0, 2.0, 3.0), (INF, INF, 1.0, 2.0, 3.0), (INF, 1.0, INF, 2.0, 3.0),
|
||||
(-INF, INF, 1.0, 2.0, 3.0), (-INF, 1.0, INF, 2.0, 3.0), (1.0, -INF, INF, 2.0, 3.0),
|
||||
]
|
||||
|
||||
|
||||
def result_token(value, greater, less, equal):
|
||||
"""A result as the fixture writes it: float32 bits, or inf / -inf / nan where the film showed 0 and the comparisons say why."""
|
||||
if bits(value) != "00000000" and bits(value) != "80000000":
|
||||
return bits(value)
|
||||
if equal:
|
||||
return bits(value)
|
||||
return "inf" if greater else ("-inf" if less else "nan")
|
||||
|
||||
|
||||
def infinite_entry(function, node_label, build_node, rows):
|
||||
count = len(rows[0])
|
||||
value_columns = []
|
||||
flag_columns = []
|
||||
for k in range(count):
|
||||
value_columns.append(f32([(0.0 if r >= len(rows) else (float(np.sign(rows[r][k])) if np.isinf(rows[r][k]) else rows[r][k])) for r in range(CASES)]))
|
||||
flag_columns.append(f32([(0.0 if r >= len(rows) else (1.0 if np.isinf(rows[r][k]) else 0.0)) for r in range(CASES)]))
|
||||
images = pack_operands(value_columns + flag_columns)
|
||||
|
||||
def build(tree, imgs):
|
||||
sockets = operand_sockets(tree, imgs, 2 * count)
|
||||
operands = []
|
||||
for k in range(count):
|
||||
hundred = tree.nodes.new("ShaderNodeMath")
|
||||
hundred.operation = "MULTIPLY"
|
||||
tree.links.new(sockets[count + k], hundred.inputs[0])
|
||||
hundred.inputs[1].default_value = 100.0
|
||||
overflow = tree.nodes.new("ShaderNodeMath")
|
||||
overflow.operation = "EXPONENT"
|
||||
tree.links.new(hundred.outputs[0], overflow.inputs[0])
|
||||
made = tree.nodes.new("ShaderNodeMath")
|
||||
made.operation = "MULTIPLY"
|
||||
tree.links.new(sockets[k], made.inputs[0])
|
||||
tree.links.new(overflow.outputs[0], made.inputs[1])
|
||||
operands.append(made.outputs[0])
|
||||
result = build_node(tree, operands)
|
||||
probes = []
|
||||
for operation in ("GREATER_THAN", "LESS_THAN", "COMPARE"):
|
||||
probe = tree.nodes.new("ShaderNodeMath")
|
||||
probe.operation = operation
|
||||
tree.links.new(result, probe.inputs[0])
|
||||
probe.inputs[1].default_value = 0.0
|
||||
if operation == "COMPARE":
|
||||
probe.inputs[2].default_value = 0.0
|
||||
probes.append(probe.outputs[0])
|
||||
return [result] + probes
|
||||
|
||||
value, greater, less, equal = bake(function + ".infinite", images, build, 4)
|
||||
cases = []
|
||||
for r, row in enumerate(rows):
|
||||
operands = " ".join(("inf" if x == INF else "-inf" if x == -INF else bits(x)) for x in row)
|
||||
cases.append("%s -> %s" % (operands, result_token(value[r], greater[r] > 0.5, less[r] > 0.5, equal[r] > 0.5)))
|
||||
return {"function": function, "node": node_label, "cases": cases}
|
||||
|
||||
|
||||
def family_infinite():
|
||||
def math_node(operation):
|
||||
def build(tree, operands):
|
||||
node = tree.nodes.new("ShaderNodeMath")
|
||||
node.operation = operation
|
||||
for k, socket in enumerate(operands):
|
||||
tree.links.new(socket, node.inputs[k])
|
||||
return node.outputs[0]
|
||||
return build
|
||||
|
||||
def smoothstep(tree, operands):
|
||||
node = tree.nodes.new("ShaderNodeMapRange")
|
||||
node.data_type = "FLOAT"
|
||||
node.interpolation_type = "SMOOTHSTEP"
|
||||
node.clamp = False
|
||||
for identifier, socket in zip(["Value", "From Min", "From Max", "To Min", "To Max"], operands):
|
||||
tree.links.new(socket, by_identifier(node.inputs, identifier))
|
||||
return node.outputs["Result"]
|
||||
|
||||
functions = [infinite_entry(function, "ShaderNodeMath " + operation, math_node(operation), rows) for function, operation, rows in INFINITE]
|
||||
functions.append(infinite_entry("cycles_map_range_smoothstep", "ShaderNodeMapRange SMOOTHSTEP", smoothstep, INFINITE_SMOOTHSTEP))
|
||||
return functions
|
||||
|
||||
|
||||
FAMILIES = {
|
||||
"math": family_math,
|
||||
"map-range": family_map_range,
|
||||
"clamp": family_clamp,
|
||||
"mix": family_mix,
|
||||
"rgb-to-bw": family_rgb_to_bw,
|
||||
"ramp": family_ramp,
|
||||
"infinite": family_infinite,
|
||||
}
|
||||
|
||||
|
||||
def identity_check():
|
||||
"""The bake must return an operand image bit for bit, or no fixture means anything."""
|
||||
rng = np.random.default_rng(1)
|
||||
column = f32(rng.uniform(-200, 200, CASES))
|
||||
images = pack_operands([column])
|
||||
|
||||
def build(tree, imgs):
|
||||
return [operand_sockets(tree, imgs, 1)[0]]
|
||||
|
||||
got = bake("identity", images, build, 1)[0]
|
||||
if not np.array_equal(got.view(np.uint32), column.view(np.uint32)):
|
||||
sys.stderr.write("cycles-oracle: the bake does not return its operands bit for bit\n")
|
||||
sys.exit(3)
|
||||
|
||||
|
||||
def kernel_check():
|
||||
"""The fixtures witness the ruled reference: Cycles' formulas with one IEEE single rounding per operation.
|
||||
|
||||
Ruling cultmath-tapes:ruling:cycles-kernel-reference-ruled. Cycles' AVX2 CPU kernel is compiled with FMA, so
|
||||
a - floor(a / b) b is one fused operation there and 10000 mod 0.1 is -1.5e-4; the SSE4.2 kernel has none, and
|
||||
gives the unfused result 0 that CultMath's C# computes. Other kernels are other builds, measured under the
|
||||
tolerance classes and never written here: run with CYCLES_CPU_NO_AVX2=1.
|
||||
"""
|
||||
a = f32([10000.0] * CASES)
|
||||
b = f32([0.1] * CASES)
|
||||
unfused = a - np.floor(a / b) * b
|
||||
images = pack_operands([a, b])
|
||||
|
||||
def build(tree, imgs):
|
||||
node = tree.nodes.new("ShaderNodeMath")
|
||||
node.operation = "FLOORED_MODULO"
|
||||
sockets = operand_sockets(tree, imgs, 2)
|
||||
tree.links.new(sockets[0], node.inputs[0])
|
||||
tree.links.new(sockets[1], node.inputs[1])
|
||||
return [node.outputs[0]]
|
||||
|
||||
got = bake("kernel", images, build, 1)[0]
|
||||
if not np.array_equal(got.view(np.uint32), unfused.astype(np.float32).view(np.uint32)):
|
||||
sys.stderr.write("cycles-oracle: this Cycles CPU kernel fuses multiply-adds, which is not the reference of ruling cycles-kernel-reference-ruled; run with CYCLES_CPU_NO_AVX2=1\n")
|
||||
sys.exit(4)
|
||||
|
||||
|
||||
def main():
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
out = argv[0]
|
||||
wanted = argv[1:] or list(FAMILIES)
|
||||
os.makedirs(out, exist_ok=True)
|
||||
identity_check()
|
||||
kernel_check()
|
||||
header = {
|
||||
"oracle": "tools/cycles-oracle/oracle.py (Blender %s, build %s, Cycles CPU, 1 sample, Emit bake, float Non-Color images)" % (PINNED_VERSION, PINNED_HASH),
|
||||
"blender": PINNED_VERSION,
|
||||
"build_hash": PINNED_HASH,
|
||||
"platform": bpy.app.build_platform.decode() if isinstance(bpy.app.build_platform, bytes) else str(bpy.app.build_platform),
|
||||
"encoding": "each case is 'operands -> results', every scalar a float32 bit pattern in hex, colours flattened as r g b",
|
||||
}
|
||||
for family in wanted:
|
||||
document = dict(header)
|
||||
document["family"] = family
|
||||
document["functions"] = FAMILIES[family]()
|
||||
text = json.dumps(document, indent=1) + "\n"
|
||||
with open(os.path.join(out, family + ".json"), "w", newline="\n") as handle:
|
||||
handle.write(text)
|
||||
print("cycles-oracle: wrote", family, len(document["functions"]), "functions")
|
||||
|
||||
|
||||
main()
|
||||
|
|
@ -7,9 +7,16 @@ files, which are licensed under the Mozilla Public License 2.0 (`MPL-2.0`):
|
|||
- the Phacelle sources compiled into `Runtime/Plugins/CultMath.dll`
|
||||
(`math.Phacelle.cs` and `CultPhasor.cs` in the GameCult/CultLib repository)
|
||||
|
||||
The package license expression is therefore `MIT AND MPL-2.0`, matching the
|
||||
`GameCult.Math` NuGet package. MPL-2.0 is file-level: it applies to those files
|
||||
and not to the rest of CultMath.
|
||||
and for the Cycles node semantics files, which are licensed under the Apache
|
||||
License 2.0 (`Apache-2.0`):
|
||||
|
||||
- `Shaders/CultMath.Cycles.hlsl`
|
||||
- the Cycles sources compiled into `Runtime/Plugins/CultMath.dll`
|
||||
(`math.Cycles.cs` in the GameCult/CultLib repository)
|
||||
|
||||
The package license expression is therefore `MIT AND MPL-2.0 AND Apache-2.0`,
|
||||
matching the `GameCult.Math` NuGet package. MPL-2.0 and Apache-2.0 are file-level:
|
||||
each applies to its files and not to the rest of CultMath.
|
||||
|
||||
The MPL-2.0 text is available from the Mozilla Foundation at
|
||||
<https://www.mozilla.org/MPL/2.0/>. Source and modification history for the
|
||||
|
|
|
|||
|
|
@ -0,0 +1,363 @@
|
|||
#ifndef CULTMATH_CYCLES_HLSL
|
||||
#define CULTMATH_CYCLES_HLSL
|
||||
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
// Cycles node semantics: what Blender's Cycles renderer computes for the Math, Map Range, Clamp, Mix and
|
||||
// RGB to BW nodes and for a Color Ramp lookup, ported function by function from the Cycles kernel
|
||||
// (intern/cycles of the Blender v5.2.2 tag; SPDX-FileCopyrightText: 2011-2022 Blender Foundation, Apache-2.0):
|
||||
// kernel/svm/{math_util,map_range,clamp,mix,color_util,ramp,convert}.h, util/math_base.h, util/color.h and
|
||||
// util/math_float3.h. Each function's comment names its Cycles source. Modified by GameCult: Cycles C++
|
||||
// becomes HLSL, a function per node operation, and the node's socket defaults and clamp options are left to
|
||||
// the caller. This file is Apache-2.0 (section 4: it was changed); CultMath.hlsl, which includes it after its
|
||||
// own definitions, is MIT (see THIRD-PARTY-NOTICES.md). math.Cycles.cs mirrors it bit for bit.
|
||||
//
|
||||
// Operands are finite floats. NaN and infinite operands are outside the oracle's reach and the mirror's.
|
||||
|
||||
// FLT_EPSILON, the least epsilon a Compare node honours.
|
||||
static const float CULTMATH_CYCLES_FLT_EPSILON = 1.1920929e-07;
|
||||
|
||||
// The scene-linear luminance coefficients Cycles reads from Blender 5.2.2's default OCIO config
|
||||
// (kernel/film linear_rgb_to_gray). The oracle fixture pins them with a case per primary.
|
||||
static const float CULTMATH_CYCLES_RGB_TO_Y_R = 0.212639093;
|
||||
static const float CULTMATH_CYCLES_RGB_TO_Y_G = 0.715169132;
|
||||
static const float CULTMATH_CYCLES_RGB_TO_Y_B = 0.0721927434;
|
||||
|
||||
// ---- Math node (kernel/svm/math_util.h svm_math; util/math_base.h) ----
|
||||
|
||||
// safe_divide
|
||||
float cultmath_cycles_divide(float a, float b) { return b != 0.0 ? a / b : 0.0; }
|
||||
|
||||
// safe_powf with compatible_powf. A negative base takes an integral exponent's parity; a base below zero
|
||||
// with a fractional exponent is 0.
|
||||
float cultmath_cycles_power(float a, float b)
|
||||
{
|
||||
float whole = b >= 0.0 ? floor(b) : ceil(b);
|
||||
if (a < 0.0 && b != whole)
|
||||
return 0.0;
|
||||
if (b == 0.0)
|
||||
return 1.0;
|
||||
if (a == 0.0)
|
||||
return 0.0;
|
||||
if (a < 0.0)
|
||||
{
|
||||
float magnitude = pow(-a, b);
|
||||
// Odd exactly when the remainder is 1: an infinite exponent leaves NaN here and is even, as powf(a, +-inf) is never negative.
|
||||
return b - 2.0 * floor(0.5 * b) == 1.0 ? -magnitude : magnitude;
|
||||
}
|
||||
return pow(a, b);
|
||||
}
|
||||
|
||||
// safe_logf
|
||||
float cultmath_cycles_logarithm(float a, float b)
|
||||
{
|
||||
if (a <= 0.0 || b <= 0.0)
|
||||
return 0.0;
|
||||
float denominator = log(b);
|
||||
return denominator != 0.0 ? log(a) / denominator : 0.0;
|
||||
}
|
||||
|
||||
// safe_sqrtf
|
||||
float cultmath_cycles_sqrt(float a) { return sqrt(max(a, 0.0)); }
|
||||
|
||||
// inversesqrtf
|
||||
float cultmath_cycles_inverse_sqrt(float a) { return a > 0.0 ? 1.0 / sqrt(a) : 0.0; }
|
||||
|
||||
// safe_modulo is fmodf, the remainder with the dividend's sign, exactly. GLSL has no fmod, and a - b trunc(a / b)
|
||||
// is wrong where the quotient rounds up to an integer or the product rounds (3 mod 0.3 is 0.29999995, not 0).
|
||||
// So this subtracts the largest power-of-two multiple of the divisor not above the remainder until the
|
||||
// remainder is below the divisor: every step is an exact subtraction, as in fmod. The loops are bounded by the
|
||||
// binades a float spans.
|
||||
float cultmath_cycles_modulo(float a, float b)
|
||||
{
|
||||
float remainder = abs(a);
|
||||
float divisor = abs(b);
|
||||
for (int steps = 0; steps < 300 && remainder >= divisor && divisor > 0.0; steps++)
|
||||
{
|
||||
float multiple = divisor;
|
||||
for (int doublings = 0; doublings < 300 && multiple * 2.0 <= remainder; doublings++)
|
||||
multiple = multiple * 2.0;
|
||||
remainder = remainder - multiple;
|
||||
}
|
||||
return b != 0.0 ? (a < 0.0 ? -remainder : remainder) : 0.0;
|
||||
}
|
||||
|
||||
// safe_floored_modulo; a build that contracts it differs by a bound stated at cycles_floored_modulo in math.Cycles.cs and the README
|
||||
float cultmath_cycles_floored_modulo(float a, float b) { return b != 0.0 ? a - floor(a / b) * b : 0.0; }
|
||||
|
||||
// wrapf (note the argument order: value, max, min); contraction bound as for floored_modulo
|
||||
float cultmath_cycles_wrap(float value, float max_value, float min_value)
|
||||
{
|
||||
float range = max_value - min_value;
|
||||
return range != 0.0 ? value - (range * floor((value - min_value) / range)) : min_value;
|
||||
}
|
||||
|
||||
// floorf(safe_divide(a, b)) * b
|
||||
float cultmath_cycles_snap(float a, float b) { return floor(cultmath_cycles_divide(a, b)) * b; }
|
||||
|
||||
// pingpongf
|
||||
float cultmath_cycles_pingpong(float a, float b)
|
||||
{
|
||||
float scaled = (a - b) / (b * 2.0);
|
||||
return b != 0.0 ? abs((scaled - floor(scaled)) * b * 2.0 - b) : 0.0;
|
||||
}
|
||||
|
||||
// smoothminf
|
||||
float cultmath_cycles_smooth_min(float a, float b, float k)
|
||||
{
|
||||
float h = max(k - abs(a - b), 0.0) / k;
|
||||
return k != 0.0 ? min(a, b) - h * h * h * k * 0.16666667 : min(a, b);
|
||||
}
|
||||
|
||||
// -smoothminf(-a, -b, k)
|
||||
float cultmath_cycles_smooth_max(float a, float b, float k) { return -cultmath_cycles_smooth_min(-a, -b, k); }
|
||||
|
||||
// NODE_MATH_COMPARE
|
||||
float cultmath_cycles_compare(float a, float b, float epsilon)
|
||||
{
|
||||
return (a == b || abs(a - b) <= max(epsilon, CULTMATH_CYCLES_FLT_EPSILON)) ? 1.0 : 0.0;
|
||||
}
|
||||
|
||||
// NODE_MATH_ROUND
|
||||
float cultmath_cycles_round(float a) { return floor(a + 0.5); }
|
||||
|
||||
// NODE_MATH_TRUNC
|
||||
float cultmath_cycles_trunc(float a) { return a >= 0.0 ? floor(a) : ceil(a); }
|
||||
|
||||
// NODE_MATH_FRACTION
|
||||
float cultmath_cycles_fract(float a) { return a - floor(a); }
|
||||
|
||||
// compatible_signf
|
||||
float cultmath_cycles_sign(float a) { return a == 0.0 ? 0.0 : (a < 0.0 ? -1.0 : 1.0); }
|
||||
|
||||
// safe_asinf
|
||||
float cultmath_cycles_arcsine(float a) { return asin(clamp(a, -1.0, 1.0)); }
|
||||
|
||||
// safe_acosf
|
||||
float cultmath_cycles_arccosine(float a) { return acos(clamp(a, -1.0, 1.0)); }
|
||||
|
||||
// compatible_atan2 (a is y, b is x)
|
||||
float cultmath_cycles_arctan2(float a, float b) { return (b == 0.0 && a == 0.0) ? 0.0 : atan2(a, b); }
|
||||
|
||||
// ---- Map Range node (kernel/svm/map_range.h svm_node_map_range) ----
|
||||
// A Map Range node's Clamp option is a Clamp node of type Range on its result (MapRangeNode::expand):
|
||||
// compose cultmath_cycles_clamp_range over these.
|
||||
|
||||
float cultmath_cycles_map_range_linear(float value, float from_min, float from_max, float to_min, float to_max)
|
||||
{
|
||||
float factor = (value - from_min) / (from_max - from_min);
|
||||
return from_max != from_min ? to_min + factor * (to_max - to_min) : 0.0;
|
||||
}
|
||||
|
||||
float cultmath_cycles_map_range_stepped(float value, float from_min, float from_max, float to_min, float to_max, float steps)
|
||||
{
|
||||
float factor = (value - from_min) / (from_max - from_min);
|
||||
factor = steps > 0.0 ? floor(factor * (steps + 1.0)) / steps : 0.0;
|
||||
return from_max != from_min ? to_min + factor * (to_max - to_min) : 0.0;
|
||||
}
|
||||
|
||||
// The value itself, not a normalized one, goes through smoothstep(from_min, from_max, .), flipped when
|
||||
// from_min > from_max (util/math_base.h smoothstep).
|
||||
float cultmath_cycles_map_range_smoothstep(float value, float from_min, float from_max, float to_min, float to_max)
|
||||
{
|
||||
float low = min(from_min, from_max);
|
||||
float high = max(from_min, from_max);
|
||||
float t = (value - low) / (high - low);
|
||||
float eased = value < low ? 0.0 : (value >= high ? 1.0 : (3.0 - 2.0 * t) * (t * t));
|
||||
float factor = from_min > from_max ? 1.0 - eased : eased;
|
||||
return from_max != from_min ? to_min + factor * (to_max - to_min) : 0.0;
|
||||
}
|
||||
|
||||
// smootherstep of map_range.h, flipped when from_min > from_max.
|
||||
float cultmath_cycles_map_range_smootherstep(float value, float from_min, float from_max, float to_min, float to_max)
|
||||
{
|
||||
float low = min(from_min, from_max);
|
||||
float high = max(from_min, from_max);
|
||||
float x = clamp(cultmath_cycles_divide(value - low, high - low), 0.0, 1.0);
|
||||
float eased = x * x * x * (x * (x * 6.0 - 15.0) + 10.0);
|
||||
float factor = from_min > from_max ? 1.0 - eased : eased;
|
||||
return from_max != from_min ? to_min + factor * (to_max - to_min) : 0.0;
|
||||
}
|
||||
|
||||
// ---- Clamp node (kernel/svm/clamp.h svm_node_clamp) ----
|
||||
|
||||
// Type Min Max
|
||||
float cultmath_cycles_clamp_minmax(float value, float min_value, float max_value) { return clamp(value, min_value, max_value); }
|
||||
|
||||
// Type Range: the bounds swap when min_value > max_value.
|
||||
float cultmath_cycles_clamp_range(float value, float min_value, float max_value)
|
||||
{
|
||||
return min_value > max_value ? clamp(value, max_value, min_value) : clamp(value, min_value, max_value);
|
||||
}
|
||||
|
||||
// ---- Mix node (kernel/svm/mix.h, color_util.h) ----
|
||||
// Factor clamping (svm_node_mix_color's saturatef) and result clamping (saturate) are the caller's.
|
||||
|
||||
// svm_node_mix_float
|
||||
float cultmath_cycles_mix_float(float t, float a, float b) { return a * (1.0 - t) + b * t; }
|
||||
|
||||
// svm_node_mix_vector
|
||||
float3 cultmath_cycles_mix_vector(float t, float3 a, float3 b) { return a * (float3(1.0, 1.0, 1.0) - t) + b * t; }
|
||||
|
||||
float3 cultmath_cycles_mix_blend(float t, float3 col1, float3 col2) { return col1 + t * (col2 - col1); }
|
||||
|
||||
float3 cultmath_cycles_mix_add(float t, float3 col1, float3 col2) { return col1 + t * ((col1 + col2) - col1); }
|
||||
|
||||
float3 cultmath_cycles_mix_mul(float t, float3 col1, float3 col2) { return col1 + t * (col1 * col2 - col1); }
|
||||
|
||||
float3 cultmath_cycles_mix_screen(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
float3 one = float3(1.0, 1.0, 1.0);
|
||||
return one - (float3(tm, tm, tm) + t * (one - col2)) * (one - col1);
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_overlay(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
return float3(
|
||||
col1.x < 0.5 ? col1.x * (tm + 2.0 * t * col2.x) : 1.0 - (tm + 2.0 * t * (1.0 - col2.x)) * (1.0 - col1.x),
|
||||
col1.y < 0.5 ? col1.y * (tm + 2.0 * t * col2.y) : 1.0 - (tm + 2.0 * t * (1.0 - col2.y)) * (1.0 - col1.y),
|
||||
col1.z < 0.5 ? col1.z * (tm + 2.0 * t * col2.z) : 1.0 - (tm + 2.0 * t * (1.0 - col2.z)) * (1.0 - col1.z));
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_sub(float t, float3 col1, float3 col2) { return col1 + t * ((col1 - col2) - col1); }
|
||||
|
||||
float3 cultmath_cycles_mix_div(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
return float3(
|
||||
col2.x != 0.0 ? tm * col1.x + t * col1.x / col2.x : col1.x,
|
||||
col2.y != 0.0 ? tm * col1.y + t * col1.y / col2.y : col1.y,
|
||||
col2.z != 0.0 ? tm * col1.z + t * col1.z / col2.z : col1.z);
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_diff(float t, float3 col1, float3 col2) { return col1 + t * (abs(col1 - col2) - col1); }
|
||||
|
||||
float3 cultmath_cycles_mix_exclusion(float t, float3 col1, float3 col2)
|
||||
{
|
||||
return max(col1 + t * ((col1 + col2 - 2.0 * col1 * col2) - col1), float3(0.0, 0.0, 0.0));
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_dark(float t, float3 col1, float3 col2) { return col1 + t * (min(col1, col2) - col1); }
|
||||
|
||||
float3 cultmath_cycles_mix_light(float t, float3 col1, float3 col2) { return col1 + t * (max(col1, col2) - col1); }
|
||||
|
||||
float3 cultmath_cycles_mix_dodge(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float3 divisor = float3(1.0, 1.0, 1.0) - t * col2;
|
||||
return float3(
|
||||
col1.x != 0.0 ? (divisor.x <= 0.0 ? 1.0 : min(col1.x / divisor.x, 1.0)) : col1.x,
|
||||
col1.y != 0.0 ? (divisor.y <= 0.0 ? 1.0 : min(col1.y / divisor.y, 1.0)) : col1.y,
|
||||
col1.z != 0.0 ? (divisor.z <= 0.0 ? 1.0 : min(col1.z / divisor.z, 1.0)) : col1.z);
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_burn(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
float3 divisor = tm + t * col2;
|
||||
float3 burnt = float3(1.0, 1.0, 1.0) - (float3(1.0, 1.0, 1.0) - col1) / divisor;
|
||||
return float3(
|
||||
divisor.x <= 0.0 ? 0.0 : clamp(burnt.x, 0.0, 1.0),
|
||||
divisor.y <= 0.0 ? 0.0 : clamp(burnt.y, 0.0, 1.0),
|
||||
divisor.z <= 0.0 ? 0.0 : clamp(burnt.z, 0.0, 1.0));
|
||||
}
|
||||
|
||||
// rgb_to_hsv of util/color.h
|
||||
float3 cultmath_cycles_rgb_to_hsv(float3 rgb)
|
||||
{
|
||||
float cmax = max(rgb.x, max(rgb.y, rgb.z));
|
||||
float cmin = min(rgb.x, min(rgb.y, rgb.z));
|
||||
float cdelta = cmax - cmin;
|
||||
float s = cmax != 0.0 ? cdelta / cmax : 0.0;
|
||||
float3 c = (float3(cmax, cmax, cmax) - rgb) / cdelta;
|
||||
float h = rgb.x == cmax ? c.z - c.y : (rgb.y == cmax ? 2.0 + c.x - c.z : 4.0 + c.y - c.x);
|
||||
h = h * 0.16666667; // Cycles' kernel is built with reciprocal math: h /= 6.0f computes h * (1 / 6.0f).
|
||||
h = h < 0.0 ? h + 1.0 : h;
|
||||
return float3(s != 0.0 ? h : 0.0, s, cmax);
|
||||
}
|
||||
|
||||
// hsv_to_rgb of util/color.h
|
||||
float3 cultmath_cycles_hsv_to_rgb(float3 hsv)
|
||||
{
|
||||
float h = hsv.x == 1.0 ? 0.0 : hsv.x;
|
||||
h = h * 6.0;
|
||||
float i = floor(h);
|
||||
float f = h - i;
|
||||
float p = hsv.z * (1.0 - hsv.y);
|
||||
float q = hsv.z * (1.0 - (hsv.y * f));
|
||||
float t = hsv.z * (1.0 - (hsv.y * (1.0 - f)));
|
||||
float3 rgb = i == 0.0 ? float3(hsv.z, t, p) :
|
||||
(i == 1.0 ? float3(q, hsv.z, p) :
|
||||
(i == 2.0 ? float3(p, hsv.z, t) :
|
||||
(i == 3.0 ? float3(p, q, hsv.z) :
|
||||
(i == 4.0 ? float3(t, p, hsv.z) : float3(hsv.z, p, q)))));
|
||||
return hsv.y != 0.0 ? rgb : float3(hsv.z, hsv.z, hsv.z);
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_hue(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float3 hsv2 = cultmath_cycles_rgb_to_hsv(col2);
|
||||
float3 hsv = cultmath_cycles_rgb_to_hsv(col1);
|
||||
float3 shifted = cultmath_cycles_hsv_to_rgb(float3(hsv2.x, hsv.y, hsv.z));
|
||||
return hsv2.y != 0.0 ? col1 + t * (shifted - col1) : col1;
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_sat(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
float3 hsv = cultmath_cycles_rgb_to_hsv(col1);
|
||||
float3 hsv2 = cultmath_cycles_rgb_to_hsv(col2);
|
||||
return hsv.y != 0.0 ? cultmath_cycles_hsv_to_rgb(float3(hsv.x, tm * hsv.y + t * hsv2.y, hsv.z)) : col1;
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_val(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
float3 hsv = cultmath_cycles_rgb_to_hsv(col1);
|
||||
float3 hsv2 = cultmath_cycles_rgb_to_hsv(col2);
|
||||
return cultmath_cycles_hsv_to_rgb(float3(hsv.x, hsv.y, tm * hsv.z + t * hsv2.z));
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_color(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float3 hsv2 = cultmath_cycles_rgb_to_hsv(col2);
|
||||
float3 hsv = cultmath_cycles_rgb_to_hsv(col1);
|
||||
float3 shifted = cultmath_cycles_hsv_to_rgb(float3(hsv2.x, hsv2.y, hsv.z));
|
||||
return hsv2.y != 0.0 ? col1 + t * (shifted - col1) : col1;
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_soft(float t, float3 col1, float3 col2)
|
||||
{
|
||||
float tm = 1.0 - t;
|
||||
float3 one = float3(1.0, 1.0, 1.0);
|
||||
float3 scr = one - (one - col2) * (one - col1);
|
||||
return tm * col1 + t * ((one - col1) * col2 * col1 + col1 * scr);
|
||||
}
|
||||
|
||||
float3 cultmath_cycles_mix_linear(float t, float3 col1, float3 col2)
|
||||
{
|
||||
return col1 + t * (2.0 * col2 + float3(-1.0, -1.0, -1.0));
|
||||
}
|
||||
|
||||
// ---- RGB to BW node (kernel/svm/convert.h NODE_CONVERT_CF) ----
|
||||
|
||||
float cultmath_cycles_rgb_to_bw(float3 color)
|
||||
{
|
||||
return color.x * CULTMATH_CYCLES_RGB_TO_Y_R + color.y * CULTMATH_CYCLES_RGB_TO_Y_G + color.z * CULTMATH_CYCLES_RGB_TO_Y_B;
|
||||
}
|
||||
|
||||
// ---- Color Ramp lookup (kernel/svm/ramp.h rgb_ramp_lookup, without extrapolation) ----
|
||||
|
||||
// Where a ramp of `size` table entries is read at t: (index, weight), the lower entry and the weight of the
|
||||
// next one. The colour is table[index] when weight is 0 and (1 - weight) table[index] + weight table[index + 1]
|
||||
// otherwise. The ramp table itself is the caller's; Blender's holds 257 entries, the ramp sampled at i / 256.
|
||||
float2 cultmath_cycles_ramp_index(float t, int size)
|
||||
{
|
||||
float f = (t > 0.0 ? (t < 1.0 ? t : 1.0) : 0.0) * (float)(size - 1);
|
||||
int index = clamp((int)f, 0, size - 1);
|
||||
float lower = (float)index;
|
||||
return float2(lower, f - lower);
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 477bb38d537244b78e3eaec093530bcc
|
||||
ShaderIncludeImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -509,6 +509,7 @@ CultCellular cultmath_cellular(float3 p)
|
|||
|
||||
#include "CultMath.Phacelle.hlsl"
|
||||
#include "CultMath.Interval.hlsl"
|
||||
#include "CultMath.Cycles.hlsl"
|
||||
#include "CultMath.Affine.hlsl"
|
||||
|
||||
float cultmath_value_noise(float2 position)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,35 @@ permutation and scale as `snoise(float3)`.
|
|||
- Upstream: <https://github.com/ashima/webgl-noise>
|
||||
- License: MIT.
|
||||
|
||||
## Cycles node semantics
|
||||
|
||||
`math.cycles_*` and `cultmath_cycles_*` port what Blender's Cycles renderer
|
||||
computes for the Math, Map Range, Clamp, Mix and RGB to BW nodes and for a Color
|
||||
Ramp lookup. Each function's comment names the Cycles file it follows, all from
|
||||
`intern/cycles` at the Blender `v5.2.2` tag: `kernel/svm/math_util.h`,
|
||||
`kernel/svm/map_range.h`, `kernel/svm/clamp.h`, `kernel/svm/mix.h`,
|
||||
`kernel/svm/color_util.h`, `kernel/svm/ramp.h`, `kernel/svm/convert.h`,
|
||||
`util/math_base.h`, `util/math_float3.h` and `util/color.h`. The code was
|
||||
changed: Cycles C++ became C# and HLSL, one function per node operation, with
|
||||
the nodes' socket defaults and clamp options left to the caller, and the modulo
|
||||
is the quotient form every CultMath target shares rather than `fmodf`. Nothing
|
||||
was read from Blender's GPL sources (`source/blender/gpu`, `source/blender/blenlib`).
|
||||
|
||||
- Copyright 2011-2022 Blender Foundation.
|
||||
- Upstream: <https://projects.blender.org/blender/blender> (`intern/cycles`).
|
||||
- License: Apache-2.0 (<https://www.apache.org/licenses/LICENSE-2.0>), which is
|
||||
per-file here: it covers `src/CultMath/math.Cycles.cs`,
|
||||
`shaders/CultMath.Cycles.hlsl` and the Unity copy of that include, and
|
||||
`shaders/CultMath.Cycles.glsl`, which is generated from that include. Each
|
||||
carries the Apache-2.0 header and this attribution. The rest of CultMath, the
|
||||
generated GLSL library included, is MIT or MPL-2.0 as above, so the
|
||||
`GameCult.Math` NuGet package and the `org.gamecult.cultmath` Unity package
|
||||
both declare `MIT AND MPL-2.0 AND Apache-2.0` and ship this file.
|
||||
- The test fixtures under `tests/CultMath.Tests/fixtures/cycles-oracle` are
|
||||
results of running Blender 5.2.2 through `tools/cycles-oracle/oracle.py`
|
||||
(GPL-3.0-or-later, a Blender Python script, shipped neither in the NuGet
|
||||
package nor in the Unity package).
|
||||
|
||||
## Phacelle Noise
|
||||
|
||||
`math.phacelle` and `cultmath_phacelle` follow Rune Skovbo Johansen's Phacelle
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"version": "0.4.1",
|
||||
"unity": "2021.3",
|
||||
"description": "Precompiled CultMath numeric primitives and matching HLSL semantics for GameCult Unity runtimes.",
|
||||
"license": "MIT AND MPL-2.0",
|
||||
"license": "MIT AND MPL-2.0 AND Apache-2.0",
|
||||
"author": {
|
||||
"name": "GameCult"
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue