| 04-04-2006, 06:02 AM | #1 |
Math for Map makers I condense a little practical computer math here, for writing efficient code and avoiding bugs that are a pain in the ass to track down. This is directed at people who know a little JASS and are familiar with algebra and a little trig but are relatively new to programming-a significant fraction of the mapping community, I believe. It will be a mix of general principles and a couple of my pet peeves, hopefully sufficiently general to be useful for many. If you have suggestions for topics, please contribute! Computational Complexity - describing an algorithm's efficiency An algorithm is a set of instructions for getting something done. To rapidly analyze the efficiency of an algorithm, we estimate its performance as we adjust its input parameters. For example, if we have an array whose values we wish to sum, we would walk down the array and sum each value individually in n steps, where n is the length of the array. We say that it takes "O(n)" time to run this algorithm. That O() notation is, for all practical purposes*, just a way to say roughly. Roughly means we ignore constant factors. For example, if our algorithm sums 3 arrays, we don't say O(3n), we still say O(n). With this language in hand, let's look at an algorithm for solving a closely related problem, something I ran into recently: JASS:function EncodeName takes string s returns integer if s == "" then return 0 else return EncodeChar(SubString(s, 0, 1)) + EncodeName(SubString(s, 1, StringLength(s))) endif endfunction At first glance this is a rather clever way to sum a string. Rather than using those loop commands, it uses recursion, breaking a string down into shorter and shorter pieces until it reaches the empty string, to which it assigns a value of zero. However, notice that at each iteration it runs two SubStrings, one of which creates a new string of length O(n). This requires O(n) time, because the computer must copy characters from the original to the new string. This algorithm is then executed n times, for a total cost of O(n^2). We could easily write an O(n) version like so: JASS:function EncodeName takes string s returns integer local integer sum = 0 local integer i = StringLength(s) -1 loop exitwhen i < 0 set sum = sum + EncodeChar(SubString(s,i,i+1)) set i = i - 1 endloop return sum endfunction The O(n^2) version works fine in practice because names are short. Suppose it takes a microsecond to run- at thirty characters n^2 is still only 900, so it will takes less than a millisecond. But at a thousand characters, an easy length for a string to get to when it is filled with fanciful color gradients, it will take a second, in comparision to the O(n) algorithm which will take only a millisecond. Trigonometry, Vectors and Lines - or ignoring everything I said above and looking for trivial optimizations PolarProjectionBJ is a terribly useful function. You take a location, choose an angle, and then place a new point any distance away on the line that your angle and location define. Internally, it uses two magical functions, Cos() and Sin(). Cos(t) returns the x coordinate of the point on a unit circle at angle t, Sin(t) returns the y coordinate. When you combine the x and y, you get the radius unit vector. This is what makes PolarProjection and polar coordinates so useful. All lines can be easily parameterized, as all their points can be radius vectors of some circle centered about a location. Using X,Y coordinates are tricky, because you have only two sets lines that are constant in one parameter- the x axis, and the y axis, with an arbitary origin. However, using polar coordinates for lines is often a case of smashing a peanut with a sledgehammer. Suppose our spell creates evenly spaced effects on a line between caster and target location (or target unit's location). It is commonly written like this: JASS:function CreateEffectsLoc takes location l1, location l2, integer n returns nothing local real angle = AngleBetweenPoints(l1,l2) local integer i = 1 local real dr = n/DistanceBetweenPoints(l1,l2) local location target loop exitwhen i >= n set target = PolarProjectionBJ(l1,i*dr,angle) call AddSpecialEffectLoc("Abilities\\Weapons\\FarseerMissile\\FarseerMissile.mdl",target) call RemoveLocation(target) set i = i + 1 endloop set target = null endfunction Let's translate this code to reals and natives to to see exactly what it does JASS:function CreateEffectsRealPolar takes real x1, real y1, real x2, real y2, integer n returns nothing local real angle = Atan2(y2-y1,x2-x1) local integer i = 0 local real dr = (n+1)/SquareRoot((x2-x1)*(x2-x1)+(y2-y1)*(y2-y1)) local real tx local real ty loop exitwhen i >= n set tx = x1+(i+1)*dr*Cos(angle) set ty = y1+(i+1)*dr*Sin(angle) call AddSpecialEffect("Abilities\\Weapons\\FarseerMissile\\FarseerMissile.mdl",tx,ty) set i = i + 1 endloop endfunction We are running Atan2 and SquareRoot at start along with one Sin and one Cos per iteration, for a total of 2+2n slow math functions! This can be improved manifoldly just by caching a little data. JASS:function CreateEffectsRealPolarCached takes real x1, real y1, real x2, real y2, integer n returns nothing local real angle = Atan2(y2-y1,x2-x1) local integer i = 0 local real dr = (n+1)/SquareRoot((x2-x1)*(x2-x1)+(y2-y1)*(y2-y1)) local real tx local real ty local real unitx = dr*Cos(angle) //No need to compute them more than once, the way PolarProjectionBJ would. local real unity = dr*Sin(angle) loop exitwhen i >= n set tx = x1+(i+1)*unitx set ty = y1+(i+1)*unity call AddSpecialEffect("Abilities\\Weapons\\FarseerMissile\\FarseerMissile.mdl",tx,ty) set i = i + 1 endloop endfunction If you need the angle of the line for another purpose, such as to create a unit facing in a certain direction, then using this "CreateEffectsRealPolarCached" version may be most appropriate. But we can do even better if we do not need the angle. (Cos(angle),Sin(angle)) is a unit vector in the direction of location 1 -> location 2. But we have a vector in this direction, and we know its length! ((x2-x1),(y2-y1))/Length = (Cos(angle),Sin(angle)) In this case, we don't even need to know its length, because we are just dividing it up into equal portions. JASS:function CreateEffectsRealCart takes real x1, real y1, real x2, real y2, integer n returns nothing local integer i = 0 local real tx local real ty local real unitx = (x2-x1) / (n+1) local real unity = (y2-y1) / (n+1) loop exitwhen i >= n set tx = x1+(i+1)*unitx set ty = y1+(i+1)*unity call AddSpecialEffect("Abilities\\Weapons\\FarseerMissile\\FarseerMissile.mdl",tx,ty) set i = i + 1 endloop endfunction There is one messy thing. If we do need to know its length, and we need proper unit vectors, we have to add a constant to the squareroot: SquareRoot(dx*dx+dy*dy+0.1). Otherwise we will run into a divide by zero, which warcraft handles by killing the thread. The 0.1 will under normal circumstances have no effect due to rounding error. For example, if we want to move a unit a constant distance in the direction of the target: JASS:function MoveUnitTowards takes unit u, real x2, real y2, real distance returns nothing local real x1 = GetUnitX(u) local real y1 = GetUnitY(u) local real dx = x2 - x1 local real dy = y2 - y1 local real scale = distance/SquareRoot(dx*dx+dy*dy+0.1) call SetUnitX(u,x1+dx*scale) call SetUnitY(u,y1+dy*scale) endfunction Numerical Error Computers can only store a finite amount of information about a number. JASS's real type uses a floating point format to do this. It is called floating point because it uses scientific notation- a number like 384 is written as (.5+.25)*2^(8+1) or .75*2^9. The result of this is rounding error. In general, a computer game is very insensitive to it, because humans can't perceive that many decimal places, optimally around 7 for the 32b floats used by JASS. However, it is possible to accidentally amplify this error. The principal way to do this is subtraction. Here is a plausible, if artificial example. Suppose you wish to measure the velocity or direction of a unit. You might write a timer callback like this: JASS://xnew,ynew,xold,yold,u are globals for brevity- use game cache in practice. //dt is the timer period function MeasureSpeed_callback takes nothing returns nothing set xnew = GetUnitX(u) set ynew = GetUnitY(u) set vx = (xnew-xold) / dt set vy = (ynew-yold) / dt set v = SquareRoot((xnew-xold)*(xnew-xold)+(ynew-yold)*(ynew-yold))/dt call BJDebugMsg("v: "+R2S(r/dt)+"vx: "+R2S(vx)+"vy: "+R2S(vy)) set udg_xold = udg_xnew set udg_yold = udg_ynew endfunction function MeasureSpeed takes nothing returns nothing set xold = GetUnitX(u) set yold = GetUnitY(u) call TimerStart(CreateTimer(),0.001,true,MeasureSpeed_callback) endfunction The unit in question is at a coordinate far off such as 10,000 by 10,000. It is user controlled and you wish to know if it tries to move north-south. Its movement speed is 100. In terms of an angle, the x,y velocities will be 100*cos(theta),100*sin(theta). Consider small values of theta, for which sin theta ~ theta. We expect to see a velocity of 100*theta, roughly. In the 0.001s between timer elapses, this will be a distance of .1*theta. Thus at one moment it may be at 10,000, the next 10,000.1 We write all 7 digits: 10,000.10 - 10,000.00 = .10 Our result has only two digits of precision! If the unit moves 10,000.009, or +- 5 degrees from the x axis, our routine will not notice- it will round to zero. In practice, though, this does not happen! Try making a wide skinny map- 10,000 or so. Move a footman out at one edge. Then try ordering him to the other edge. If you do not order him far enough off the x axis, he will move with 0 y velocity! When he gets close enough to the point, his behavior "snaps" and he will suddenly start to move in the proper direction.** That example tried to pack all the error in at once. You can also accumulate it in little bits and pieces. For example, if you use reals to count, you will get it wrong with out a tolerance check- JASS:function sum takes nothing returns nothing local real sigma = 0 local real inc = 0.1 local integer i = 0 loop exitwhen sigma >= 10000 set i = i + 1 set sigma = sigma + inc if(ModuloInteger(i,1000)==999) then call TriggerSleepAction(0.) call BJDebugMsg("sigma: "+R2S(sigma)+"i: "+I2S(i)) endif endloop call BJDebugMsg("final i: "+I2S(i)) endfunction While both these examples are artificial, you can imagine a middle ground. For example, if you tried to measure the distance moved over several iterations of the MeasureSpeed loop by summing the difference at each iteration, you would rapidly accumulate severe error even if you used a larger time interval. This could be fixed by writing down the original position, so that you compute the total distance afresh when necessary. *:Technically, O(f(n)) means that asymptotically the function is bounded above by f(n) **Rounding error, or pathing map oddity? |
| 04-19-2006, 06:58 PM | #2 |
I myself expirienced painfully the PolarprojectionBJ thing. This blocked me out for weeks 'n weeks. Your tut is very usefull, not for me, but there are at least 100 which should first read this tut before posting threads, asking things you mentioned and explained. +Rep |
| 04-20-2006, 12:01 AM | #3 |
Wow! I didint realize using the traditional polar projection was so leaky. I didint even realize how off reals were in small quantities. +Rep |
| 04-20-2006, 03:38 AM | #4 | |
Quote:
Polar projection is not actually leaky. At least, that's not the problem here. At worst it only leaks two location references (not the objects themselves) per call: JASS:function PolarProjectionBJ takes location source, real dist, real angle returns location local real x = GetLocationX(source) + dist * Cos(angle * bj_DEGTORAD) local real y = GetLocationY(source) + dist * Sin(angle * bj_DEGTORAD) return Location(x, y) endfunction By "leaky abstraction" he meant that the function is deceptively simple; you don't realize that it takes a ton of computing power to do. People use it in loops and periodic functions without realizing it and it slows their computer to a halt. Also, reals are not at all off in small quantities. Warcraft can hold extremely small numbers, like 4.26547 x 10^-35 with extremely high precision (less than a millionth of a percent error, regardless of how small your number is). The problem comes with when you're trying to look at the far decimal places on big numbers. Take a number like 0.236218. A float can hold that just fine. Now watch what happens when you add a million to it and subtract it again: 0.236218 + 1000000 = 1000000.236218 Warcraft can't hold 13 decimal places so it rounds it, which ends up being something like 1000000.2. Subtract a million again: 1000000.2 - 1000000 = 0.2 This simple calculation should in theory give us our number back, 0.236218, but because of rounding error the answer was 0.2, which is off by 15%. This is not because the number is small, but because a million is so large that it makes 0.036218 insignificant. The moral here is to be very careful when subtracting numbers from eachother. If they are very close together, your result will get rounded. Often, if the numbers are close enough together, the rounding will simply leave zero, which can cause divide-by-zero or can simply make nothing happen at all. Very frustrating bugs to track down without this knowledge. |
| 04-21-2006, 12:29 PM | #5 |
Well, either way, I did not realize the amount of time you could save by avoiding blizzards math functions :P. It seems like every single math function (Atan, Atan2, Square, Pow, Cos, Sin, Tan, Cot (Might not have tan/cot)) in addition *timesaving* things like locations cause leaks, so its really best to stick with the absolute basics in jass anyways... Atleast, if you're gunning for speed. And, reals/floats/doubles/extendeds/w/e you want to call them, they're never & never will be 100% accurate. 1/3 = .3333333333333333333333333333333 ect, but you cant hold the whole value of it, you're going to end up losing some accuracy. If you have something that can hold 8k digits, you probably wont notice it. But, blizzard decided to use... 3-4. I will never get why they did that, you end up losing a lot of precision. |
| 04-21-2006, 01:24 PM | #6 |
Change these to AddSpecialEffect JASS:call AddSpecialEffectLoc("Abilities\\Weapons\\FarseerMissile\\FarseerMissile.mdl",tx,ty) |
| 04-21-2006, 02:42 PM | #7 | |
Quote:
They don't use 3-4 decimal place precision. That would be retarded. You're probably just thinking that because R2S 'rounds' it to 3 decimals. |
| 04-21-2006, 07:01 PM | #8 |
Oops! Thanks, Thunder. Blackroot, using locations can be just as fast. See http://www.wc3jass.com/viewtopic.php?t=2542 |
| 04-21-2006, 11:40 PM | #9 |
Just a tip for your pythagorean theorems: As an alternative to doing (x2-x1)*(x2-x1) in case you have huge variable names or whatever, I believe you can use Pow(x2-x1, 2.0) to square it. |
| 04-22-2006, 12:47 AM | #10 | |
Quote:
They don't leak. They're just a mathematical operation. It's not Blizzard's fault that trigonometric functions are slow; it's just a fact of life. 100 years ago, before we had all this fancy electronics, if you wanted to calculate the sine/cosine of a number directly with any decent amount of precision it could take an hour of work. You could take the Taylor expansion and start doing some rather large long-division, or you could take a known sine value and repeatedly doing halfangle/doubleangle computations, or a variety of other ways that take a ridiculous amount of computation... ![]() This is why hundreds, even thousands of years ago, people would spend years writing and publishing "sine tables", which would be an enormous book containing nothing but the sine of every number with, say, 5 decimal places. If you wanted the sine of a number, say 0.26349 you had to break out the book, find page 26, scroll across to the 34th row and 9th column, and that was your answer. This is not Blizzard's fault. Taking a sine is a big deal. People really take their calculator for granted. |
| 04-22-2006, 08:23 AM | #11 | |
Quote:
That's doing exactly the same thing, and might even be slower depending on how exactly the native Pow function works. The only advantage it would have with large variable names is number of characters? Another useful thing to avoid having to SquareRoot in cases is when you are checking the distance between points against a certain number (using mathematical comparison, 2 > 1 etc). Instead of: JASS:if SquareRoot(x*x+y*y) > dist then JASS:if x*x+y*y > dist*dist then |
| 04-22-2006, 08:52 AM | #12 |
Vuen, if blizzard really wanted fast trig, they could do tables. Especially easy because we only have single precision. Anyway, Cos/Sin are fast enough as is- they execute in less than 2us on my 1.6GHzish machine. I think most of that time is the interpreter.. As a side note I think the way the C++ stdlib does it is by transforming first to 0 to pi/2, doing a couple 3rd 5th or 7th angle formulas and then two or three taylor terms suffices. Since it's an alternating series you can maybe do the trick of taking only half of the next term to get another order of precision, but maybe it converges too fast for that. Blu: good suggestion, thanks As for Pow, the thing that scares me even more than efficiency is that JASS automatically typecasts between reals and integers. |
