LINQ-style array operations for UdonSharp, powered by compile-time Source Generator expansion.
UdonSharp (VRChat’s C# → Udon compiler) does not support lambda expressions, delegates, or LINQ. ULinq works around this at compile time — a Roslyn Source Generator rewrites your lambda calls into plain loops before UdonSharp ever sees them. The result is readable code with zero runtime overhead.
// You write this:
int[] evens = numbers.Where(x => x % 2 == 0);
int sum = evens.Aggregate(0, (acc, x) => acc + x);
// UdonSharp compiles this (generated automatically):
var __temp_0 = new int[numbers.Length];
var __count_0 = 0;
foreach (var __t_0 in numbers)
{
if (!(__t_0 % 2 == 0)) continue;
__temp_0[__count_0] = __t_0;
__count_0++;
}
var evens = new int[__count_0];
for (var __i_0 = 0; __i_0 < __count_0; __i_0++)
evens[__i_0] = __temp_0[__i_0];
var sum = 0;
foreach (var __t_1 in evens)
sum = sum + __t_1;
Udon VM — VRChat’s runtime — has hard constraints that rule out conventional LINQ strategies:
| Constraint | Implication |
|---|---|
| No delegates / function pointers | Func<T,R>, Action<T> cannot exist at runtime |
| No generic struct instantiation | Struct-based enumerator chains (ZLinq) are impossible |
| No try/catch | Error-handling wrappers cannot be used |
| Arrays only | No List<T>, Span<T>, or custom collections |
Compile-time inlining is the only viable path. ULinq’s Source Generator resolves all lambdas at build time, emitting Udon-compatible loops. The [Inline] attribute marks methods for expansion — this is a general-purpose mechanism, not limited to the built-in operators.
array.Where(...).Select(...).FirstOrDefault(...) composes naturallyx => { var y = x * 2; return y + 1; }return, if conditions, method arguments, while/for conditions, expression-bodied membersa.Any(...) && b.All(...) correctly skips b when a is false; same for || and ternary ?:[Inline] methods with if/switch early returns; the SG expands them the same wayCopy the ULinq folder into your Unity project’s Assets/ directory.
Assets/
└── ULinq/
├── Editor/ ← Harmony hook (Editor-only)
├── Runtime/ ← ULinq operators + [Inline] attribute
├── Plugins/ ← Source Generator DLL
└── Tests/ ← Unity EditMode tests
https://aiczk.github.io/VPM/index.jsonWarning:
[Inline]methods and user code must both be in Assembly-CSharp (the default assembly). Adding an.asmdefto either side will break ULinq — the Source Generator can only read syntax trees within the same compilation unit.
SumExampleusing UdonSharp;
using ULinq;
using UnityEngine;
public class SumExample : UdonSharpBehaviour
{
void Start()
{
int[] numbers = new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
int[] evens = numbers.Where(x => x % 2 == 0);
int sum = evens.Aggregate(0, (acc, x) => acc + x);
Debug.Log("Sum of evens: " + sum); // 30
}
}
Sum of evens: 30 in the ConsoleWhen installed via VCC, an
Assets/ULinq/folder is created automatically on the first domain reload containingRuntime/andPlugins/. You may move these files anywhere underAssets/.
// Transform & Filter
T[] .Select<T,R>(x => ...) // → R[] Transform
T[] .Select<T,R>((x, i) => ...) // → R[] Transform with index
T[] .Where(x => ...) // → T[] Filter
T[] .Where((x, i) => ...) // → T[] Filter with index
T[] .ForEach(x => ...) // → void Side effect
T[] .SelectMany(x => ...) // → R[] Flat map
// Quantifiers
T[] .Any(x => ...) // → bool Existential
T[] .All(x => ...) // → bool Universal
T[] .Count(x => ...) // → int Count matches
// Element access (predicate)
T[] .First(x => ...) // → T First match (throws if none)
T[] .FirstOrDefault(x => ...) // → T First match (default if none)
T[] .Last(x => ...) // → T Last match (throws if none)
T[] .LastOrDefault(x => ...) // → T Last match (default if none)
T[] .Single(x => ...) // → T Single match (throws if 0 or >1)
T[] .SingleOrDefault(x => ...) // → T Single match (throws if >1)
// Aggregation
T[] .Aggregate((a, b) => ...) // → T Reduce
T[] .Aggregate(seed, (a, x) => ...) // → R Fold
// Conditional take/skip
T[] .TakeWhile(x => ...) // → T[] Take while true
T[] .SkipWhile(x => ...) // → T[] Skip while true
// Zip
T[] .Zip(U[], (x, y) => ...) // → R[] Merge two arrays
// Selector overloads (int/float)
T[] .Min(x => ...) // → int/float
T[] .Max(x => ...) // → int/float
T[] .Sum(x => ...) // → int/float
T[] .Average(x => ...) // → float
// Sorting (int/float keys, stable insertion sort)
T[] .OrderBy(x => ...) // → T[] Ascending
T[] .OrderByDescending(x => ...) // → T[] Descending
// Numeric (int[]/float[])
.Sum() .Min() .Max() .Average()
// Element access
T[] .First() T[] .Last()
T[] .FirstOrDefault() T[] .LastOrDefault()
T[] .Single() T[] .SingleOrDefault()
T[] .ElementAt(i) T[] .ElementAtOrDefault(i)
// Quantifiers & query
T[] .Any() T[] .Count()
T[] .Contains(value) T[] .SequenceEqual(other)
// Slicing & combining
T[] .Take(n) T[] .Skip(n) T[] .Concat(other)
T[] .Append(v) T[] .Prepend(v) T[] .Reverse()
T[] .ToArray() T[] .DefaultIfEmpty() T[] .DefaultIfEmpty(v)
// Set operations (O(n²), .Equals() comparison)
T[] .Distinct() T[] .Union(other)
T[] .Intersect(other) T[] .Except(other)
All operators use DataToken (no generics). Requires using VRC.SDK3.Data;.
// Lambda operators
DataList .ForEach(x => ...) // → void Side effect
DataList .Select(x => ...) // → DataList Transform
DataList .Where(x => ...) // → DataList Filter
DataList .Any(x => ...) // → bool Existential
DataList .All(x => ...) // → bool Universal
DataList .Count(x => ...) // → int Count matches
DataList .First(x => ...) // → DataToken First match (throws if none)
DataList .FirstOrDefault(x => ...) // → DataToken First match (default if none)
DataList .Last(x => ...) // → DataToken Last match (throws if none)
DataList .LastOrDefault(x => ...) // → DataToken Last match (default if none)
DataList .Single(x => ...) // → DataToken Single match (throws if 0 or >1)
DataList .SingleOrDefault(x => ...) // → DataToken Single match (throws if >1)
DataList .Aggregate(seed, (a, x) => ...) // → DataToken Fold
DataList .TakeWhile(x => ...) // → DataList Take while true
DataList .SkipWhile(x => ...) // → DataList Skip while true
// Non-lambda operators
DataList .Any() DataList .First()
DataList .FirstOrDefault() DataList .Last()
DataList .LastOrDefault() DataList .Single()
DataList .SingleOrDefault() DataList .Take(n)
DataList .Skip(n) DataList .Concat(other)
DataList .Append(token) DataList .Prepend(token)
DataList .Distinct() DataList .SequenceEqual(other)
Build Time UdonSharp Compile
┌──────────┐ ┌───────────────┐ ┌─────────┐ ┌──────────────────┐
│ Your .cs │───>│ Source │───>│ Library/│───>│ Harmony patch │
│ (lambda) │ │ Generator │ │ .g.cs │ │ intercepts read │
└──────────┘ │ expands │ └─────────┘ │ → returns .g.cs │
│ [Inline] │ └────────┬─────────┘
└───────────────┘ │
┌────────▼─────────┐
│ UdonSharp sees │
│ plain loops │
│ → Udon bytecode │
└──────────────────┘
[Inline] method calls with lambda arguments.cs to Library/ULinqGenerated/UdonSharpUtils.ReadFileTextSync returns the expanded sourceAny static extension method with [Inline] is automatically expanded:
using ULinq;
public static class MyExtensions
{
[Inline]
public static T[] TakeWhile<T>(this T[] array, Func<T, bool> predicate)
{
var temp = new T[array.Length];
var count = 0;
foreach (var t in array)
{
if (!predicate(t)) break;
temp[count] = t;
count++;
}
var result = new T[count];
for (var i = 0; i < count; i++)
result[i] = temp[i];
return result;
}
}
The SG inlines the method body at each call site, replacing predicate(t) with the actual lambda expression and resolving type parameters.
[Inline] methods and calling code must be in Assembly-CSharp (no asmdef separation)Where(...).Select(...) creates a temp array between steps). Avoid calling chains in Update() every frame — the intermediate allocations create GC pressure. Cache results or use event-driven patterns insteadThe expanded source files are written to Library/ULinqGenerated/. Each .udon.g.cs file contains a // @source: header pointing to the original source. Open these files to verify how your lambdas are expanded — useful for debugging unexpected behavior or understanding the generated code.
Q: UdonSharp errors like “does not support … SimpleLambdaExpression”
A: The Source Generator DLL is not being loaded. Check that Plugins/ULinq.SourceGenerator.dll.meta has labels: [RoslynAnalyzer] at the top level (not inside PluginImporter:). Reimport the DLL in Unity.
Q: Changes to [Inline] methods are not reflected
A: After rebuilding the SG DLL, copy it to Plugins/ and verify the MD5 hash matches. Then change the content of any .cs file (not just timestamp) to invalidate Bee’s cache.
Q: “Unloading broken assembly” warning in Console
A: The SG DLL’s .meta file has enabled: 1 under the Editor platform. Set Editor: enabled: 0 and Exclude Editor: 1. The labels: [RoslynAnalyzer] label is sufficient — Unity passes it via -analyzer: automatically.
com.aiczk.ulinq
https://github.com/aiczk/ULinq#readme
1.0.5 (変更履歴)
2022.3 以降
なし
なし