Blog

ENGINEERING NOTE

[Unreal Engine] Scientific Notation

Scientific Notation 키우기 게임이나 방치형 게임에서는 재화, 공격력, 경험치 등의 수치가 기하급수적으로 증가하는 경우가 많다.

Unreal Engine

Scientific Notation

키우기 게임이나 방치형 게임에서는 재화, 공격력, 경험치 등의 수치가 기하급수적으로 증가하는 경우가 많다. 이런 게임에서는 수십 자리, 많게는 수백 자리 이상의 숫자를 다루게 되는데, 일반적인 int, long, int64와 같은 기본 자료형만으로는 이를 표현하거나 계산하는 데 한계가 있다.

이러한 문제를 해결하기 위해 흔히 Scientific Notation(과학적 숫자 표기법)을 사용한다.

과학적 표기법은 매우 큰 수를 가수(Mantissa)지수(Exponent)의 조합으로 표현하는 방식으로, 다음과 같이 나타낼 수 있다.

Value = Mantissa × 10Exponent

예를 들어 123,456,789라는 숫자는 다음과 같이 표현할 수 있다.

1.23456789 × 108

이처럼 실제 숫자 전체를 저장하는 대신 가수와 지수 두 개의 값만 관리하면 매우 큰 수를 효율적으로 표현하고 계산할 수 있으며, 이는 Big Number 시스템의 기반이 된다.



Scientific Notation Implementation

Struct 정의

Scientific Notation을 이용한 Big Number 시스템은 기본적으로 Mantissa(가수)와 Exponent(지수) 두 개의 값만 저장하면 된다.

이를 위해 다음과 같이 구조체를 정의할 수 있다.

FBigNumber.h

cpp
#pragma once

#include "CoreMinimal.h"
#include "FBigNumber.generated.h"

USTRUCT(BlueprintType)
struct FBigNumber
{
	GENERATED_BODY()

	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	float Mantissa = 0.0f;

	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	int32 Exponent = 0;
};

Normalize

Scientific Notation에서는 동일한 값을 여러 형태로 표현할 수 있다. 예를 들어 아래 값들은 모두 같은 숫자를 의미한다.

text
1234 × 10^5
123.4 × 10^6
12.34 × 10^7
1.234 × 10^8

하지만 이러한 상태를 그대로 허용하면 비교 연산이나 사칙 연산을 수행할 때 불필요한 예외 처리가 많아진다. 따라서 Big Number 시스템에서는 항상 Mantissa를 일정 범위로 유지하도록 정규화(Normalize) 과정을 수행한다.

1 <= |Mantissa| < 10

NormalizeBigNumber

cpp
UFUNCTION(BlueprintPure)
static FBigNumber NormalizeBigNumber(const FBigNumber& Number);

FBigNumber UBigNumberFunctionLibrary::NormalizeBigNumber(const FBigNumber& Number)
{
	float Mantissa = Number.Mantissa;
	int32 Exponent = Number.Exponent;

	constexpr int32 MaxExponent = 99999;

	if (FMath::IsNearlyZero(Mantissa))
	{
		return { 0.0f, 0 };
	}

	while (FMath::Abs(Mantissa) >= 10.0f)
	{
		Mantissa /= 10.0f;
		++Exponent;
	}

	while (FMath::Abs(Mantissa) < 1.0f)
	{
		Mantissa *= 10.0f;
		--Exponent;

		if (FMath::IsNearlyZero(Mantissa))
		{
			return { 0.0f, 0 };
		}
	}

	if (Exponent >= MaxExponent)
	{
		const float Sign = Mantissa < 0.0f ? -1.0f : 1.0f;
		return { 9.999999f * Sign, MaxExponent };
	}

	return { Mantissa, Exponent };
}

Addition

Scientific Notation 형태로 저장된 두 Big Number를 더할 때는 일반적인 실수 덧셈과는 다른 과정이 필요하다.

예를 들어 아래 두 수를 생각해보자.

3.5 × 108 2.1 × 106

두 수의 지수(Exponent)가 서로 다르기 때문에 Mantissa만 단순히 더할 수는 없다. 따라서 먼저 두 수를 동일한 Exponent 기준으로 맞춰야 한다.

3.5 × 108 0.021 × 108

그 후 Mantissa를 더한다.

(3.5 + 0.021) × 108

이처럼 Big Number의 덧셈은 Exponent를 일치시킨 뒤 Mantissa를 더하는 방식으로 수행된다.

AddBigNumbers

cpp
UFUNCTION(BlueprintPure)
static FBigNumber AddBigNumbers(const FBigNumber& A, const FBigNumber& B);

FBigNumber UBigNumberFunctionLibrary::AddBigNumbers(const FBigNumber& A, const FBigNumber& B)
{
	int32 ExponentResult;
	double MantissaA = A.Mantissa;
	double MantissaB = B.Mantissa;

	int32 ExponentDifference = A.Exponent - B.Exponent;

	if (FMath::Abs(ExponentDifference) > 15)
	{
		if (ExponentDifference > 0)
		{
			return A;
		}
		return B;
	}

	if (A.Exponent >= B.Exponent)
	{
		MantissaB /= FMath::Pow(10.0, ExponentDifference);
		ExponentResult = A.Exponent;
	}
	else
	{
		MantissaA /= FMath::Pow(10.0, -ExponentDifference);
		ExponentResult = B.Exponent;
	}

	double MantissaResult = MantissaA + MantissaB;

	FBigNumber CombinedResult;
	CombinedResult.Mantissa = MantissaResult;
	CombinedResult.Exponent = ExponentResult;

	return NormalizeBigNumber(CombinedResult);
}

Subtraction

Big Number의 뺄셈 역시 덧셈과 마찬가지로 먼저 Exponent를 일치시킨 뒤 Mantissa를 계산해야 한다.

CompareBigNumbers

뺄셈을 수행하기 전에 먼저 두 수의 대소 관계를 확인해야 한다.

모든 Big Number는 Normalize 과정을 거쳐 저장되므로 Exponent가 큰 수가 항상 더 큰 값을 의미한다. 따라서 먼저 Exponent를 비교하고, Exponent가 같은 경우에만 Mantissa를 비교하면 두 수의 대소 관계를 빠르게 판별할 수 있다.

반환값의미
1A > B
0A == B
-1A < B

cpp
UFUNCTION(BlueprintPure)
static int32 CompareBigNumbers(const FBigNumber& A, const FBigNumber& B);

int32 UBigNumberFunctionLibrary::CompareBigNumbers(const FBigNumber& A, const FBigNumber& B)
{
	if (A.Exponent > B.Exponent)
	{
		return 1;
	}
	else if (A.Exponent < B.Exponent)
	{
		return -1;
	}
	else
	{
		if (A.Mantissa > B.Mantissa)
		{
			return 1;
		}
		else if (A.Mantissa < B.Mantissa)
		{
			return -1;
		}
		else
		{
			return 0;
		}
	}
}

SubtractBigNumbers

cpp
UFUNCTION(BlueprintPure)
static FBigNumber SubtractBigNumbers(const FBigNumber& A, const FBigNumber& B);

FBigNumber UBigNumberFunctionLibrary::SubtractBigNumbers(const FBigNumber& A, const FBigNumber& B)
{
	int32 ExponentResult;
	double MantissaA;
	double MantissaB;

	// 음수에 대한 지원 X
	if (CompareBigNumbers(A, B) == -1)
	{
		return FBigNumber(0, 0);
	}
	
	int32 ExponentDifference = A.Exponent - B.Exponent;
	
	if (CompareBigNumbers(A, B) == 1)
	{
		ExponentResult = A.Exponent;
		MantissaA = A.Mantissa;
		MantissaB = B.Mantissa / FMath::Pow(10.0, ExponentDifference);
	}
	else
	{
		ExponentResult = B.Exponent;
		MantissaA = A.Mantissa / FMath::Pow(10.0, FMath::Abs(ExponentDifference));
		MantissaB = B.Mantissa;
	}
	
	double MantissaResult = MantissaA - MantissaB;
	
	FBigNumber CombinedResult;
	CombinedResult.Mantissa = MantissaResult;
	CombinedResult.Exponent = ExponentResult;
	
	return NormalizeBigNumber(CombinedResult);
}

Multiplication

Scientific Notation 형태의 수는 곱셈을 매우 간단하게 수행할 수 있다. 일반적인 수의 곱셈은 자릿수가 커질수록 계산이 복잡해지지만, Scientific Notation에서는 Mantissa끼리 곱하고 Exponent끼리 더하면 된다.

예를 들어 다음과 같은 두 수를 곱한다고 가정하면

2.5 × 108 4.0 × 103

Scientific Notation의 곱셈 법칙에 따라 다음과 같이 계산할 수 있다.

(2.5 × 4.0) × 10(8 + 3) = 10.0 × 1011

MultiplyBigNumbers

cpp
UFUNCTION(BlueprintPure)
static FBigNumber MultiplyBigNumbers(const FBigNumber& A, const FBigNumber& B);

FBigNumber UBigNumberFunctionLibrary::MultiplyBigNumbers(const FBigNumber& A, const FBigNumber& B)
{
	double MantissaResult = A.Mantissa * B.Mantissa;
	int32 ExponentResult = A.Exponent + B.Exponent;

	FBigNumber CombinedResult;
	CombinedResult.Mantissa = MantissaResult;
	CombinedResult.Exponent = ExponentResult;

	return NormalizeBigNumber(CombinedResult);
}

Division

곱셈이 Mantissa를 곱하고 Exponent를 더하는 방식이었다면, 나눗셈은 Mantissa를 나누고 Exponent를 빼는 방식으로 계산한다.

예를 들어 다음과 같은 두 수를 나눈다고 가정하면

8.0 × 109 2.0 × 103

Scientific Notation의 나눗셈 법칙에 따라 다음과 같이 계산할 수 있다.

(8.0 × 109) \div (2.0 × 103) = \left(\frac{8.0}{2.0}\right) × 10(9-3) = 4.0 × 106

DivideBigNumbers

cpp
UFUNCTION(BlueprintPure)
static FBigNumber DivideBigNumbers(const FBigNumber& A, const FBigNumber& B);

FBigNumber UBigNumberFunctionLibrary::DivideBigNumbers(const FBigNumber& A, const FBigNumber& B)
{
	if (FMath::IsNearlyZero(B.Mantissa))
	{
		return { 0.0, 0 };
	}

	double MantissaResult = A.Mantissa / B.Mantissa;
	int32 ExponentResult = A.Exponent - B.Exponent;

	FBigNumber CombinedResult;
	CombinedResult.Mantissa = MantissaResult;
	CombinedResult.Exponent = ExponentResult;

	return NormalizeBigNumber(CombinedResult);
}

Power

Big Number의 거듭제곱 연산은 곱셈이나 나눗셈보다 조금 더 복잡하다. Scientific Notation 형태의 수를 거듭제곱하면 Mantissa와 Exponent 모두에 영향을 주기 때문이다.

예를 들어 다음 연산을 수행한다고 가정하자.

(3.0×105)2.5=3.02.5×1012.5

이처럼 2.5제곱같이 실수 형태의 제곱은 Exponent가 정수가 아닌 값이 될 수 있다는 점이다. Exponent가 실수가 되면 현재 Big Number 구조체에 저장할 수 없다.

여기서 다음과 같은 형태로 분해가 가능하다.

1012.5 = 1012×100.5

따라서 다음과 같은 형태로 변환하여 분리된 소수부는 Mantissa에 계산시킬수 있다.

3.02.5×1012.5=(3.02.5×100.5)×1012

PowerBigNumber

cpp
UFUNCTION(BlueprintPure)
static FBigNumber PowerBigNumber(const FBigNumber& Base, double power);

FBigNumber UBigNumberFunctionLibrary::PowerBigNumber(const FBigNumber& Base, double Power)
{
	if (Base.Mantissa <= 0.0)
	{
		if (FMath::IsNearlyZero(Base.Mantissa) && Power > 0.0)
		{
			return { 0.0, 0 }; 
		}
		return { 0.0, 0 };
	}

	double RawExponent = static_cast<double>(Base.Exponent) * Power;

	double IntegralPart = 0.0;
	double FractionalPart = FMath::Modf(RawExponent, &IntegralPart);
	
	int32 ExponentResult = FMath::TruncToInt(IntegralPart);
	double MantissaResult = FMath::Pow(Base.Mantissa, Power) * FMath::Pow(10.0, FractionalPart);

	FBigNumber CombinedResult;
	CombinedResult.Mantissa = MantissaResult;
	CombinedResult.Exponent = ExponentResult;

	return NormalizeBigNumber(CombinedResult);
}

Root

제곱근(Square Root)은 거듭제곱 연산의 특수한 형태로 볼 수 있다. 따라서 별도의 제곱근 알고리즘을 구현하지 않고, 앞서 구현한 PowerBigNumber()를 재사용하여 제곱근을 계산할 수 있다.

SqrtBigNumber

cpp
UFUNCTION(BlueprintPure)
static FBigNumber SqrtBigNumber(const FBigNumber& Number);

FBigNumber UBigNumberFunctionLibrary::SqrtBigNumber(const FBigNumber& Number)
{
	FBigNumber PowResult = PowerBigNumber(Number, 0.5);

	return NormalizeBigNumber(PowResult);
}

Formatting

지금까지 구현한 Big Number는 내부적으로 Scientific Notation 형태로 저장된다.

  • Mantissa = 1.234567
  • Exponent = 6

이를 다시 사용자에게 읽기 친숙한 표기법으로 변환할 때 주로 다음과 같은 축약 표기법을 사용한다.

1,234 → 1.234K 1,234,567 → 1.234M 1,234,567,890 → 1.234B

Suffix Array

먼저 10³ 단위마다 사용할 접미사를 정의한다.

cpp
static const TArray<FString> Suffixes;

const TArray<FString> UBigNumberFunctionLibrary::Suffixes = {
	TEXT(""),
	TEXT("K"),
	TEXT("M"),
	TEXT("B"),
	TEXT("T"),
	TEXT("Qa"),
	TEXT("Qi"),
	TEXT("Sx"),
	TEXT("Sp"),
	TEXT("Oc"),
	TEXT("No"),
	TEXT("Dc")
};

각 접미사는 다음 값을 의미한다.

SuffixValue
K10³
M10⁶
B10⁹
T10¹²
Qa10¹⁵
Qi10¹⁸
Sx10²¹
Sp10²⁴
Oc10²⁷
No10³⁰
Dc10³³

BigNumberToString

Classical 표기법은 10³ 단위를 기준으로 접미사를 선택한다.

cpp
UFUNCTION(BlueprintPure)
static FString BigNumberToString(const FBigNumber& Number, int32 Decimal = 3);

FString UBigNumberFunctionLibrary::BigNumberToString(const FBigNumber& Number, int32 Decimal)
{
	// Classical 표기법
	float MantissaResult; 
	
	if (Number.Mantissa == 0.0)
	{
		return TEXT("0");
	}
	
	int32 ExponentGroup = FMath::Floor(Number.Exponent / 3.0f);
	int32 GroupExponent = ExponentGroup * 3;
	
	if (Suffixes.IsValidIndex(ExponentGroup))
	{
		MantissaResult = Number.Mantissa * FMath::Pow(10.0f, Number.Exponent - GroupExponent);
		FString ResultString = FString::Printf(TEXT("%.*f%s"), Decimal, MantissaResult, *Suffixes[ExponentGroup]);
		return ResultString;
	}
	
	if (Number.Exponent < 0)
	{
		MantissaResult = Number.Mantissa * FMath::Pow(10.0f, Number.Exponent);
		FString ResultString = FString::Printf(TEXT("%.*f"), Decimal, MantissaResult);
		return ResultString;
	}
	
	// Scientific Notation으로 표현
	return FString::Printf(TEXT("%.*fe%d"), Decimal, Number.Mantissa, Number.Exponent);
}

결과

Idle/Clicker 게임에서 흔히 사용되는 숫자 시스템을 구현할 수 있다.