ET04: floating point stability
- Forums:
В Лекция 4, при примерите как да се ползват новите Vector и Matrix класове, имаше слайд ("Нетривиален пример") как да изчислим ортонормиран базис около зададен вектор.
Кодът там ползва random вектор, за да създаде първия перпендикулярен на v вектор (a), но има защити, които проверяват дали fabs(dot(randomVector, v)) > 0.9. По време на лекцията обясних,
че това е нужно, защото не е добра идея да подаваме на векторното произведение почти еднакви вектори, тъй като полученият трети вектор ще страда от намалена прецизност. Този вид проблеми се обобщават с термина Numerical stability и се анализират при проектиране на алгоритми, които ползват floating point числа.
Една от причините в курса по 3D графика да ползваме Vector-и от double-и е за да не се налага да мислим особено много за този род проблеми, тъй като там навлизаме в доста дълбоки води.
Ако се цели да се направи най-бързият рейтрейсър възможен, всички матрици и вектори биха ползвали float-и, а алгоритмите ще са старателно написани по най-стабилният (числено) начин, а това не би било особено fun занимание ако целта ни не е да ставаме експерти по numerical stability.
Все пак, дори и за сметките да ползваме double, floating point проблемите си стоят, просто изскачат по-трудно. Ще дам два примера
10 ротации с 1/10 оборот
В branch "et04-fp-stability" съм качил следното парче код:
const double N = 10; Vector a(0.9372438, -0.2811731, 0.2061936); Vector b = a; Matrix m = rotationAroundY(toRadians(360 / N)); printf("Iter b dot(a, b) a^b len(a^b) norm(a^b) dot(a, nc) dot(b, nc)\n"); for (int i = 1; i <= int(N); i++) { b *= m; // rotate by 1/N turn around Y Vector c = a^b; Vector nc = c; nc.normalize(); printf("%4d ", i); printVec(b); printf("%8.5f ", dot(a, b)); printVec(c); printf(" %.5f ", c.length()); printVec(nc); printf("%9.6f %9.6f\n", dot(a, nc), dot(b, nc)); } return 0;
Какво се случва там?
- a е единичен вектор, който е само леко отместен от +X;
- b в началото е копие на a;
- на всяка итерация на for-цикъла, b се завърта с 1/10 оборот около оста Y (т.е. с по 36° на всяка итерация), и се смятат:
- скаларното произведение dot(a, b);
- векторното произведение a^b;
- резултатният вектор a^b се нормира (и се нарича nc);
- изчисляват се скаларните произведения между nc и {a, b};
- докато се върти около Y, векторът b се движи по повърхността на един широк конус (с връх 0, 0, 0 и ъгъл надолу към -Y);
- на 10тата итерация, b вече е завъртян на 360° и би следвало да съвпада с a.
Чистата математика би предсказала:
- a^b да е ненулев вектор за всички итерации без 10тата;
- Резултатите да са недефинирани за 10тата итерация. Би следвало a^b да е нулев вектор, който няма как да бъде нормиран;
- dot(a, nc) и dot(b, nc) следва да са нули винаги (резултатният вектор трябва да е перпендикулярен спрямо двата входни);
Реално се случва следното:
Iter | b | dot(a, b) | a^b | len(a^b) | nc = norm(a^b) | dot(a, nc) | dot(b, nc) |
---|---|---|---|---|---|---|---|
1 | ( 0.637, -0.281, 0.718) | 0.82412 | (-0.144, -0.541, -0.084) | 0.56642 | (-0.254, -0.956, -0.149) | 0.000000 | 0.000000 |
2 | ( 0.094, -0.281, 0.955) | 0.36364 | (-0.211, -0.876, -0.237) | 0.93154 | (-0.226, -0.940, -0.255) | 0.000000 | 0.000000 |
3 | (-0.486, -0.281, 0.828) | -0.20553 | (-0.175, -0.876, -0.400) | 0.97865 | (-0.179, -0.895, -0.409) | 0.000000 | 0.000000 |
4 | (-0.879, -0.281, 0.384) | -0.66600 | (-0.050, -0.541, -0.511) | 0.74595 | (-0.067, -0.726, -0.685) | -0.000000 | 0.000000 |
5 | (-0.937, -0.281, -0.206) | -0.84188 | ( 0.116, -0.000, -0.527) | 0.53966 | ( 0.215, -0.000, -0.977) | 0.000000 | -0.000000 |
6 | (-0.637, -0.281, -0.718) | -0.66600 | ( 0.260, 0.541, -0.443) | 0.74595 | ( 0.348, 0.726, -0.593) | -0.000000 | 0.000000 |
7 | (-0.094, -0.281, -0.955) | -0.20553 | ( 0.327, 0.876, -0.290) | 0.97865 | ( 0.334, 0.895, -0.296) | -0.000000 | 0.000000 |
8 | ( 0.486, -0.281, -0.828) | 0.36364 | ( 0.291, 0.876, -0.127) | 0.93154 | ( 0.312, 0.940, -0.136) | 0.000000 | 0.000000 |
9 | ( 0.879, -0.281, -0.384) | 0.82412 | ( 0.166, 0.541, -0.016) | 0.56642 | ( 0.293, 0.956, -0.029) | -0.000000 | -0.000000 |
10 | ( 0.937, -0.281, 0.206) | 1.00000 | ( 0.000, 0.000, 0.000) | 0.00000 | ( 0.226, 0.904, 0.362) | 0.032212 | 0.032212 |
На проблемната 10-та итерация наистина a^b изглежда да е нулев вектор, и дължината му е не по-голяма от 10-5, обаче векторът дефакто не е нулев и може да бъде нормиран, като даже излиза в правилната полуполовина и дори е прилично перпендикулярен на a и b! Разликата до прав ъгъл е под 2°.
Примерът горе е показателен за следните неща:
- Всяка манипулация на точки в пространството (в случая ротация) леко ги деформира. Например: единичен вектор след 1000 ротации може да изисква ново нормиране;
- 10 ротации от по 1/10 би следвало да резултират в идентитет, но това не е така;
- Точки, които би трябвало да съвпадат, всъщност "почти съвпадат", и в известен смисъл това е по-опасно от пълното съвпадение. При идентични вектори, горния код би крашнал, заради опита да нормираме нулев вектор. Тук тази математически невалидна операция тихичко минава и даже тихичко дава почти вярни резултати, а това би могло да ни докара много трудни за хващане бъгове
Light visibility biasing
В кода на visible() функцията при изчисление на сенките за Lambert шейдъра приложихме една промяна, за да избегнем визуалните проблеми, които се виждаха в началото (виж видеото от лекцията в 1:48:08). Този подход се нарича най-общо се нарича biasing и решава следният проблем:
- повечето Geometry::intersect реализации не правят твърда разлика дали обектът се намира пред или зад нас, т.е. подобно на Sphere::intersect връщат една или повече пресечни точки във вид на параметър p, където пресечната точка ip се смята като ray.start + ray.dir * p. Съответно, ако има пресечни точки "наобратно" зад ray.start, те ще се появят като решения с p ≤ 0
- когато трасираме лъч от камерата към геометриите, самата камера е невидима и можем да предполагаме, че в близост до нея няма нежелани геометрии; т.е. околността на ray.start е "чиста"
- когато трасираме visibility лъчите от пресечните точки към точката на светлината, вече ray.start тръгва от някоя геометрия и околността е "нечиста"
- т.е. е възможно да получим тези "фантомни" пресечни точки с p ≅ 0
- възможно е заради грешки от закръгленията, досущ като в предния пример, да ни се получи параметър p = много малко, но положително число. Тогава ще си мислим, че нещо засенчва пресечната точка ip.
- нещо повече, възможно е действително геометрията да се самозасенчва. Представете си, че шейдваме точка във вътрешната извивка на един тор. Лъчът в посока към светлината спокойно може да има 4 решения на параметричното уравнение ray.start + ray.dir * p:
- отрицателно от близката външна стена на тора, зад нас
- ≅ 0, за шейдваната точка
- положително, от отсрещната вътрешна стена
- още по-голямо, от отсрещната външна стена
Нас реално ни интересува №3, но ако стратегията на Torus::intersect е да ни върне "най-малкото положително решение", то би могло да ни върне №2.
- в лекция 4 решихме този проблем, като не трасираме visibility лъчи от геометрията към лампата, а от лампата към геометрията, и освен това сравняваме разстоянията чрез епсилон сравнение. Това, на което разчитаме, без да го заявяваме явно, е че околността на точката на лампата ще е "чиста";
- Това е вярно само за Omni Light, обаче. Като реализираме Rectangular light, например, тя е видима и си е геометрия като всяка друга, т.е. трасирането на visibility rays и в двете посоки е потенциално проблематично
Истинското решение на този проблем изисква или по някакъв начин да можем да викаме трасиране на лъч с игнориране на някакъв списък от околности около пресечни точки (което понякога има смисъл, особено за триъгълни мрежи: там можем да игнорираме триъгълника, от който "тръгваме"), или да отместим началото и края на лъча, така че да са мъничко, но измеримо отместени от повърхностите на геометриите. Именно това се нарича biasing, и изглежда така:
- Искаме да проверим за visibility между intersectionInfo.ip и lightPos
- Отместваме intersectionInfo.ip по протежение на нормала на пресечната точка: tempA = intersectionInfo.ip + intersectionInfo.norm * 1e-6
- Отместваме lightPos по протежение на посоката на светлината (което ще е добре дефинирано за светлини като RectLight, както ще видите по-натам): tempB = lightPos + lightDir * 1e-6
- Трасираме лъча между tempA и tempB, и проверяваме за пресечни точки на разстояние по-малко от distance(intersectionInfo.ip, lightPos). Ако не намерим такива - значи шейдваната точка е осветена от тази светлина.